Skip to main content

aion_package/
admission.rs

1//! Declared-contract admission: does a caller-supplied value satisfy the
2//! schema the package committed to?
3//!
4//! A `.v4` package identity binds its declared shapes — the workflow input
5//! schema and every signal payload schema — into the content hash. This module
6//! is the one place that answers "does this value satisfy that declaration",
7//! so the engine can refuse a mismatch at the boundary the value enters
8//! through, BEFORE anything is recorded or consumed.
9//!
10//! It is deliberately pure: no I/O, no logging, no state. The caller supplies
11//! the schema and the value and receives either admission or a refusal naming
12//! every field that did not match.
13
14use serde_json::Value;
15
16use crate::contract::{PackageContract, SignalContract};
17
18/// One declared schema in a package contract that no validator can compile,
19/// named by the declaration that carries it.
20///
21/// An unenforceable declaration is not a harmless one: [`admit_value`] can only
22/// answer [`AdmissionError::UnusableSchema`] for it, and every admission
23/// boundary's response to that is to let the value through unchecked. A
24/// contract carrying one is a contract the engine cannot honour, and the
25/// operator needs to be told WHICH declaration — a package can declare dozens.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct UnenforceableSchema {
28    /// The declaration the schema belongs to, in the author's own vocabulary.
29    pub declaration: String,
30    /// The compiler's own reason for refusing the schema.
31    pub reason: String,
32}
33
34impl std::fmt::Display for UnenforceableSchema {
35    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(formatter, "{}: {}", self.declaration, self.reason)
37    }
38}
39
40/// A caller-supplied value did not satisfy a declared contract schema.
41///
42/// Both variants are caller-facing refusals, never engine faults: they are
43/// returned to whoever supplied the value, with nothing recorded.
44#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
45pub enum AdmissionError {
46    /// The declared schema itself is not a usable JSON Schema.
47    ///
48    /// This is a defect in the package, not in the caller's value, and is kept
49    /// distinct so it can never be reported as "your payload is wrong".
50    #[error("the declared schema is not a valid JSON Schema: {reason}")]
51    UnusableSchema {
52        /// Why the schema could not be compiled.
53        reason: String,
54    },
55
56    /// The value did not satisfy the declared schema.
57    #[error("{violations}")]
58    Mismatch {
59        /// Every violation, each naming the JSON-pointer location that failed.
60        violations: String,
61    },
62}
63
64/// Whether `schema` declares nothing about the values it covers.
65///
66/// `null` is what a manifest that never declared a shape carries — the
67/// [`PackageContract::default`] input and output schemas are both `null` — and
68/// JSON Schema's own accept-everything forms (`{}` and `true`) say the same
69/// thing in the schema's own vocabulary. None of them constrains anything, so
70/// there is nothing to admit a value against and admission is skipped entirely
71/// rather than run against a vacuous ruler.
72#[must_use]
73pub fn declares_nothing(schema: &Value) -> bool {
74    match schema {
75        Value::Null | Value::Bool(true) => true,
76        Value::Object(members) => members.is_empty(),
77        _ => false,
78    }
79}
80
81/// Checks that `schema` can be compiled into a validator at all, describing
82/// why when it cannot.
83///
84/// An uncompilable declared schema is not a harmless one. [`admit_value`] can
85/// only answer [`AdmissionError::UnusableSchema`], and the engine's response to
86/// that is to admit the value UNCHECKED and log a warning — so a schema that
87/// does not compile silently switches admission off for everything it declares.
88/// Authoring and packaging tools call this so the author meets the problem at
89/// the door, where it is a diagnostic, instead of at a running server, where it
90/// is an absence.
91///
92/// A schema that declares nothing ([`declares_nothing`]) is usable by
93/// definition: admission is skipped for it deliberately, not by failure.
94///
95/// # Errors
96///
97/// Returns the compiler's own reason when `schema` is not a usable validator.
98pub fn schema_is_usable(schema: &Value) -> Result<(), String> {
99    if declares_nothing(schema) {
100        return Ok(());
101    }
102    jsonschema::validator_for(schema)
103        .map(|_| ())
104        .map_err(|error| error.to_string())
105}
106
107/// Checks `value` against the declared JSON Schema `schema`.
108///
109/// Every violation is reported, each prefixed with the JSON-pointer location
110/// of the field that failed (or `<root>` when the whole value is wrong), so a
111/// caller can see exactly what to correct. Nothing is truncated: the refusal
112/// is the operator's only diagnostic, and a partial list would hide a second
113/// mistake behind the first.
114///
115/// # Errors
116///
117/// Returns [`AdmissionError::UnusableSchema`] when the declared schema cannot
118/// be compiled, and [`AdmissionError::Mismatch`] when `value` violates it.
119pub fn admit_value(schema: &Value, value: &Value) -> Result<(), AdmissionError> {
120    let validator =
121        jsonschema::validator_for(schema).map_err(|error| AdmissionError::UnusableSchema {
122            reason: error.to_string(),
123        })?;
124    if validator.is_valid(value) {
125        return Ok(());
126    }
127    let violations = validator
128        .iter_errors(value)
129        .map(|error| {
130            let location = error.instance_path().to_string();
131            if location.is_empty() {
132                format!("<root>: {error}")
133            } else {
134                format!("{location}: {error}")
135            }
136        })
137        .collect::<Vec<_>>()
138        .join("; ");
139    // `is_valid` said no, so `iter_errors` yields at least one error; an empty
140    // render would still be an honest refusal rather than a silent admission.
141    Err(AdmissionError::Mismatch { violations })
142}
143
144impl PackageContract {
145    /// Every declared schema in this contract that cannot be compiled into a
146    /// validator, in declaration order.
147    ///
148    /// This is the whole contract surface, not only the schemas today's
149    /// admission boundaries happen to read: a declaration is identity-bound,
150    /// so a package that commits to a shape no validator can compile has
151    /// promised something it can never be held to, whichever boundary reaches
152    /// it first. An empty result means every declaration this package makes is
153    /// one the engine can actually enforce.
154    ///
155    /// A declaration that constrains nothing ([`declares_nothing`]) is not a
156    /// failure — it says nothing on purpose, and admission skips it
157    /// deliberately rather than by breaking.
158    #[must_use]
159    pub fn unenforceable_schemas(&self) -> Vec<UnenforceableSchema> {
160        let mut found = Vec::new();
161        let mut check = |declaration: String, schema: &Value| {
162            if let Err(reason) = schema_is_usable(schema) {
163                found.push(UnenforceableSchema {
164                    declaration,
165                    reason,
166                });
167            }
168        };
169
170        check("the workflow input type".to_owned(), &self.input_schema);
171        check("the workflow result type".to_owned(), &self.output_schema);
172        for entry in &self.additional_workflows {
173            let workflow_type = &entry.workflow_type;
174            check(
175                format!("the input type of workflow `{workflow_type}`"),
176                &entry.input_schema,
177            );
178            check(
179                format!("the result type of workflow `{workflow_type}`"),
180                &entry.output_schema,
181            );
182        }
183        for signal in &self.signals {
184            let name = &signal.name;
185            check(
186                format!("the payload type of signal `{name}`"),
187                &signal.input_schema,
188            );
189        }
190        for child in &self.children {
191            let name = &child.name;
192            check(
193                format!("the input type of child workflow `{name}`"),
194                &child.input_schema,
195            );
196            check(
197                format!("the result type of child workflow `{name}`"),
198                &child.output_schema,
199            );
200        }
201        for worker in &self.workers {
202            let queue = &worker.task_queue;
203            for action in &worker.actions {
204                let name = &action.name;
205                check(
206                    format!("the parameter types of activity `{name}` on queue `{queue}`"),
207                    &action.input_schema,
208                );
209                check(
210                    format!("the result type of activity `{name}` on queue `{queue}`"),
211                    &action.output_schema,
212                );
213            }
214        }
215        found
216    }
217
218    /// The declared signal record for `signal_name`, when this contract
219    /// declares one.
220    #[must_use]
221    pub fn declared_signal(&self, signal_name: &str) -> Option<&SignalContract> {
222        self.signals
223            .iter()
224            .find(|signal| signal.name == signal_name)
225    }
226
227    /// Every declared signal name, in stable sorted order.
228    ///
229    /// Used to tell a caller which names the package actually accepts when it
230    /// named one the package does not declare.
231    #[must_use]
232    pub fn declared_signal_names(&self) -> Vec<&str> {
233        let mut names = self
234            .signals
235            .iter()
236            .map(|signal| signal.name.as_str())
237            .collect::<Vec<_>>();
238        names.sort_unstable();
239        names
240    }
241
242    /// The declared input schema for the entry `workflow_type`.
243    ///
244    /// A package archive can carry several workflow entries under one identity:
245    /// the primary entry's schema is [`PackageContract::input_schema`] and every
246    /// additional entry carries its own in
247    /// [`PackageContract::additional_workflows`]. An additional entry is matched
248    /// by name first, so a synthesized child entry is never validated against
249    /// the primary entry's shape; any other name is the primary entry, which is
250    /// the only other type a catalog can hold for this identity.
251    #[must_use]
252    pub fn entry_input_schema(&self, workflow_type: &str) -> &Value {
253        self.additional_workflows
254            .iter()
255            .find(|entry| entry.workflow_type == workflow_type)
256            .map_or(&self.input_schema, |entry| &entry.input_schema)
257    }
258}
259
260#[cfg(test)]
261#[path = "admission_tests.rs"]
262mod tests;