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;