//! Compile-once cache for operation `publish_schema` validators //! (ADR-046 §4, ADR-068; review-001 post-remediation follow-up). //! //! The `/publish` route validates each chunk against the op's //! `publish_schema`. Compiling the schema per request is both a //! per-request CPU/allocation cost on the hot path and — worse — the //! GW-01 follow-up fail-open: when compilation fails, chunks flowed to //! the handler **unvalidated** behind only a `tracing::warn`, quietly //! reopening the transport-dependent validation gap GW-01 closed. //! //! This cache compiles each op's schema **once** and remembers the //! outcome — [`PublishSchemaCache::validator`] returns either a shared //! compiled validator or a cached compile failure. The failure is not //! retried until the registration changes: an un-compilable schema is a //! deployment defect (registry assembly), not a transient condition. //! //! Invalidation is by schema **value**: the raw `serde_json::Value` is //! stored alongside the compiled validator, and a request whose op's //! current `publish_schema` differs from the cached one recompiles. This //! makes re-registration (hot reload) correct without lifetime coupling //! to registry internals: a new schema value is picked up on the next //! `/publish` request to that op. //! //! The registry is defined not to mutate after assembly //! (`CachedOpenAPIDoc` relies on the same invariant), so in practice //! each schema compiles once per process — the value comparison is //! defense in depth for re-assembly paths. use std::collections::HashMap; use std::sync::Arc; use alkcall::protocol::wire::CallError; use alkcall::registry::registration::OperationRegistry; use jsonschema::Validator; use parking_lot::RwLock; use serde_json::Value; /// Compiled-validator (or recorded compile failure) per operation name. #[derive(Clone, Default)] pub(crate) struct PublishSchemaCache { entries: Arc>>, } #[derive(Clone)] enum CacheEntry { Compiled { schema: Value, validator: Arc, }, /// Compile failed; the error was already logged at compile-attempt /// time and every subsequent request fails closed. Held only so a /// schema whose value still equals the failing one is not /// recompiled (and re-logged) on every request. Failed { schema: Value }, } impl PublishSchemaCache { pub(crate) fn new() -> Self { Self::default() } /// The compiled validator for `op`'s current `publish_schema`, from /// the cache when the schema value is unchanged, compiled otherwise. /// /// `Ok(None)` means the op has no `publish_schema` (chunks flow /// unvalidated, matching the wire path). `Err` is a **failed** /// compile — cached, loud, never a validator: the caller must fail /// the publish rather than skip validation. pub(crate) fn validator( &self, registry: &OperationRegistry, op: &str, ) -> Result>, CompileFailed> { let Some(schema) = registry .registration(op) .and_then(|reg| reg.spec.publish_schema.clone()) else { return Ok(None); }; if let Some(cached) = Self::lookup(&self.entries, op, &schema) { return cached; } Self::compile(&self.entries, op, schema) } fn lookup( entries: &RwLock>, op: &str, schema: &Value, ) -> Option>, CompileFailed>> { let guard = entries.read(); match guard.get(op) { Some(CacheEntry::Compiled { schema: cached_schema, validator, }) if cached_schema == schema => Some(Ok(Some(Arc::clone(validator)))), Some(CacheEntry::Failed { schema: cached_schema, }) if cached_schema == schema => Some(Err(CompileFailed { operation: op.to_string(), })), _ => None, } } fn compile( entries: &RwLock>, op: &str, schema: Value, ) -> Result>, CompileFailed> { match jsonschema::options().build(&schema) { Ok(validator) => { let validator = Arc::new(validator); entries.write().insert( op.to_string(), CacheEntry::Compiled { schema, validator: Arc::clone(&validator), }, ); Ok(Some(validator)) } Err(error) => { tracing::error!( operation = %op, error = %error, "publish_schema failed to compile; every /publish request to this operation \ fails (fail-closed) until the schema is corrected and re-registered" ); entries .write() .insert(op.to_string(), CacheEntry::Failed { schema }); Err(CompileFailed { operation: op.to_string(), }) } } } } /// A `publish_schema` that could not be compiled (review-001 /// post-remediation, GW-01 follow-up): the publish request fails closed /// with `INTERNAL`; the compile error itself stays in the server log — /// an untrusted schema's error text must not be echoed on the wire /// (same discipline as the `/openapi.json` cache-miss 500). #[derive(Debug)] pub(crate) struct CompileFailed { operation: String, } impl CompileFailed { pub(crate) fn call_error(&self) -> CallError { CallError::internal(format!( "publish_schema for operation '{}' failed to compile; publishing is disabled for \ this operation", self.operation )) } } #[cfg(test)] mod tests { use super::*; use alkcall::core::types::Capabilities; use alkcall::registry::registration::{ make_sink_handler, HandlerKind, HandlerRegistration, OperationProvenance, }; use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; use serde_json::json; fn registry_with_pub_op(name: &str, schema: Option) -> OperationRegistry { let mut registry = OperationRegistry::new(); let mut spec = OperationSpec::new( name, OperationType::Pub, Visibility::External, json!({}), json!({}), vec![], AccessControl::default(), None, ); if let Some(schema) = schema { spec = spec.with_publish_schema(schema); } registry .register(HandlerRegistration::new( spec, HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move { alkcall::protocol::wire::ResponseEnvelope::ok(ctx.request_id, Value::Null) })), OperationProvenance::Local, None, None, Capabilities::new(), )) .unwrap(); registry } #[test] fn no_publish_schema_yields_ok_none() { let cache = PublishSchemaCache::new(); let registry = registry_with_pub_op("ingest/push", None); assert!(matches!( cache.validator(®istry, "ingest/push"), Ok(None) )); } #[test] fn unknown_op_yields_ok_none() { let cache = PublishSchemaCache::new(); let registry = OperationRegistry::new(); assert!(matches!(cache.validator(®istry, "no/such"), Ok(None))); } #[test] fn valid_schema_compiles_and_validates() { let cache = PublishSchemaCache::new(); let registry = registry_with_pub_op( "ingest/typed", Some(json!({ "type": "object", "required": ["n"] })), ); let validator = cache.validator(®istry, "ingest/typed").unwrap().unwrap(); assert!(validator.is_valid(&json!({ "n": 1 }))); assert!(!validator.is_valid(&json!({}))); } #[test] fn repeated_lookups_return_the_same_cached_validator() { let cache = PublishSchemaCache::new(); let registry = registry_with_pub_op( "ingest/typed", Some(json!({ "type": "object", "required": ["n"] })), ); let first = cache.validator(®istry, "ingest/typed").unwrap().unwrap(); let second = cache.validator(®istry, "ingest/typed").unwrap().unwrap(); assert!( Arc::ptr_eq(&first, &second), "cache must serve the same compiled validator" ); } #[test] fn uncompilable_schema_fails_closed_and_stays_failed() { let cache = PublishSchemaCache::new(); let registry = registry_with_pub_op( "ingest/broken", Some(json!({ "required": "n" })), // `required` must be an array ); assert!(cache.validator(®istry, "ingest/broken").is_err()); assert!( cache.validator(®istry, "ingest/broken").is_err(), "the cached failure must persist — no retry, no validator" ); } }