Skip to main content

faucet_core/contract/
mod.rs

1//! Data contracts (issue #204): a declarative, versioned schema + semantic
2//! contract for a pipeline's output — required fields, types, nullability,
3//! enum sets, regex patterns, numeric/length bounds — enforced per page after
4//! transforms and quality checks and before the sink write. Breaches `fail`,
5//! `quarantine` (via the DLQ), or `warn` per the contract-level policy.
6//!
7//! Pure evaluation; the pipeline wires DLQ routing and metrics in
8//! `run_stream` / `instrumented_apply_contract`.
9
10pub mod compile;
11pub mod config;
12pub mod export;
13
14pub use compile::{CompiledContract, CompiledField};
15pub use config::{ContractFieldType, ContractSpec, FieldContract, OnBreach};
16pub use export::{OPENLINEAGE_SCHEMA_FACET_URL, to_json_schema, to_openlineage_facet};
17
18use crate::contract::compile::type_matches;
19use crate::error::FaucetError;
20use serde_json::Value;
21
22/// One observed contract breach.
23#[derive(Debug, Clone)]
24pub struct ContractViolation {
25    /// Stable rule label (closed set, safe as a metric label): `not_object`,
26    /// `missing`, `null`, `type`, `enum`, `pattern`, `range`, `length`,
27    /// `extra_field`.
28    pub rule: &'static str,
29    /// Breaching field name; `None` for whole-record rules (`not_object`).
30    pub field: Option<String>,
31    /// Human-readable breach message (goes into errors / DLQ envelopes).
32    pub message: String,
33    /// 0-based position within the original page — the DLQ envelope's
34    /// `record_index` contract (position within the page that failed).
35    pub page_index: usize,
36}
37
38impl ContractViolation {
39    /// One-line human description: `field 'x': <message> (<rule>)`.
40    pub fn describe(&self) -> String {
41        match &self.field {
42            Some(f) => format!("field '{f}': {} ({})", self.message, self.rule),
43            None => format!("{} ({})", self.message, self.rule),
44        }
45    }
46}
47
48/// A record removed from the page by a `quarantine` breach, destined for the
49/// DLQ.
50#[derive(Debug, Clone)]
51pub struct ViolatingRecord {
52    pub record: Value,
53    pub violation: ContractViolation,
54}
55
56/// Result of applying the contract pass to one page.
57#[derive(Debug, Clone, Default)]
58pub struct ContractOutcome {
59    /// Records that conform (plus, under `warn`, records that breached).
60    pub survivors: Vec<Value>,
61    /// Breaching records routed to the DLQ (`quarantine` only).
62    pub quarantined: Vec<ViolatingRecord>,
63    /// Breaches observed under `warn` (their records stay in `survivors`).
64    pub warned: Vec<ContractViolation>,
65}
66
67/// Apply the contract to one page. Pure: no metrics, no DLQ I/O.
68///
69/// Records are evaluated in order; per record, the first breach wins (fields
70/// in declared order, then the extra-field check). Policy:
71/// - `fail` — return [`FaucetError::ContractViolation`] on the first breach.
72/// - `quarantine` — move breaching records to `quarantined`.
73/// - `warn` — record the breach in `warned`, keep the record in `survivors`.
74pub fn apply_contract(
75    records: Vec<Value>,
76    contract: &CompiledContract,
77) -> Result<ContractOutcome, FaucetError> {
78    let mut out = ContractOutcome::default();
79    for (page_index, rec) in records.into_iter().enumerate() {
80        match evaluate_record(&rec, contract, page_index) {
81            None => out.survivors.push(rec),
82            Some(violation) => match contract.on_breach {
83                OnBreach::Fail => {
84                    return Err(FaucetError::ContractViolation {
85                        version: contract.version.clone(),
86                        message: violation.describe(),
87                    });
88                }
89                OnBreach::Quarantine => out.quarantined.push(ViolatingRecord {
90                    record: rec,
91                    violation,
92                }),
93                OnBreach::Warn => {
94                    out.warned.push(violation);
95                    out.survivors.push(rec);
96                }
97            },
98        }
99    }
100    Ok(out)
101}
102
103/// Evaluate one record against the contract. Returns the first breach, or
104/// `None` when the record conforms.
105fn evaluate_record(
106    rec: &Value,
107    contract: &CompiledContract,
108    page_index: usize,
109) -> Option<ContractViolation> {
110    let violation = |rule: &'static str, field: Option<&str>, message: String| ContractViolation {
111        rule,
112        field: field.map(str::to_string),
113        message,
114        page_index,
115    };
116
117    let Some(obj) = rec.as_object() else {
118        return Some(violation(
119            "not_object",
120            None,
121            format!("record is not a JSON object (got {})", json_type_name(rec)),
122        ));
123    };
124
125    for f in &contract.fields {
126        let value = match obj.get(&f.name) {
127            None => {
128                if f.required {
129                    return Some(violation(
130                        "missing",
131                        Some(&f.name),
132                        "required field is missing".into(),
133                    ));
134                }
135                continue; // absent optional field: nothing else to check
136            }
137            Some(v) => v,
138        };
139        if value.is_null() {
140            if f.nullable {
141                continue; // explicit null allowed: skip the value checks
142            }
143            return Some(violation(
144                "null",
145                Some(&f.name),
146                "field is null but not nullable".into(),
147            ));
148        }
149        if !type_matches(value, f.field_type) {
150            return Some(violation(
151                "type",
152                Some(&f.name),
153                format!("expected {}, got {}", f.field_type, json_type_name(value)),
154            ));
155        }
156        if let Some(values) = &f.allowed_values
157            && !enum_contains(values, value)
158        {
159            return Some(violation(
160                "enum",
161                Some(&f.name),
162                format!("value {value} is not in the allowed set"),
163            ));
164        }
165        if let Some(re) = &f.pattern {
166            // compile() guarantees pattern ⇒ string type, checked above.
167            let s = value.as_str().unwrap_or_default();
168            if !re.is_match(s) {
169                return Some(violation(
170                    "pattern",
171                    Some(&f.name),
172                    format!("value {value} does not match pattern '{re}'"),
173                ));
174            }
175        }
176        if (f.min.is_some() || f.max.is_some())
177            && let Some(n) = value.as_f64()
178        {
179            if let Some(min) = f.min
180                && n < min
181            {
182                return Some(violation(
183                    "range",
184                    Some(&f.name),
185                    format!("value {n} is below the minimum {min}"),
186                ));
187            }
188            if let Some(max) = f.max
189                && n > max
190            {
191                return Some(violation(
192                    "range",
193                    Some(&f.name),
194                    format!("value {n} is above the maximum {max}"),
195                ));
196            }
197        }
198        if f.min_length.is_some() || f.max_length.is_some() {
199            let len = value.as_str().map(|s| s.chars().count()).unwrap_or(0);
200            if let Some(min) = f.min_length
201                && len < min
202            {
203                return Some(violation(
204                    "length",
205                    Some(&f.name),
206                    format!("string length {len} is below the minimum {min}"),
207                ));
208            }
209            if let Some(max) = f.max_length
210                && len > max
211            {
212                return Some(violation(
213                    "length",
214                    Some(&f.name),
215                    format!("string length {len} is above the maximum {max}"),
216                ));
217            }
218        }
219    }
220
221    if !contract.allow_extra_fields {
222        for key in obj.keys() {
223            if !contract.declared.contains(key) {
224                return Some(violation(
225                    "extra_field",
226                    Some(key),
227                    "field is not declared in the contract and allow_extra_fields is false".into(),
228                ));
229            }
230        }
231    }
232
233    None
234}
235
236fn json_type_name(v: &Value) -> &'static str {
237    match v {
238        Value::Null => "null",
239        Value::Bool(_) => "boolean",
240        Value::Number(_) => "number",
241        Value::String(_) => "string",
242        Value::Array(_) => "array",
243        Value::Object(_) => "object",
244    }
245}
246
247/// Enum membership with numeric-aware equality: two JSON numbers compare by
248/// value (so an integer-spelled enum member `1` matches a conforming `1.0` on a
249/// `number` field), everything else by exact structural equality. `compile`
250/// already accepts an integer enum on a number field, and the per-record type
251/// check runs before this, so no cross-type coercion sneaks in (audit #321 M2).
252fn enum_contains(allowed: &[Value], value: &Value) -> bool {
253    allowed.iter().any(|v| match (v, value) {
254        (Value::Number(a), Value::Number(b)) => {
255            if let (Some(x), Some(y)) = (a.as_i64(), b.as_i64()) {
256                x == y
257            } else if let (Some(x), Some(y)) = (a.as_u64(), b.as_u64()) {
258                x == y
259            } else {
260                matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
261            }
262        }
263        _ => v == value,
264    })
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use serde_json::json;
271
272    fn compiled(v: Value) -> CompiledContract {
273        let spec: ContractSpec = serde_json::from_value(v).unwrap();
274        CompiledContract::compile(&spec).unwrap()
275    }
276
277    fn basic(on_breach: &str) -> CompiledContract {
278        compiled(json!({
279            "version": "1.0.0",
280            "on_breach": on_breach,
281            "fields": [
282                { "name": "id", "type": "integer", "min": 0 },
283                { "name": "status", "type": "string", "enum": ["open", "closed"] },
284                { "name": "email", "type": "string", "required": false,
285                  "nullable": true, "pattern": "^.+@.+$", "min_length": 3 }
286            ]
287        }))
288    }
289
290    #[test]
291    fn conforming_page_passes_untouched() {
292        let page = vec![
293            json!({"id": 1, "status": "open", "email": "a@b.c"}),
294            json!({"id": 2, "status": "closed"}), // optional email absent
295            json!({"id": 3, "status": "open", "email": null}), // nullable
296        ];
297        let out = apply_contract(page.clone(), &basic("fail")).unwrap();
298        assert_eq!(out.survivors, page);
299        assert!(out.quarantined.is_empty());
300        assert!(out.warned.is_empty());
301    }
302
303    #[test]
304    fn numeric_enum_matches_across_int_float_spelling() {
305        // #321 M2: an integer-spelled enum on a `number` field must accept a
306        // conforming float value (and vice-versa) instead of spuriously breaching.
307        let c = compiled(json!({
308            "version": "1.0.0",
309            "on_breach": "fail",
310            "fields": [ { "name": "rate", "type": "number", "enum": [1, 2] } ]
311        }));
312        // 1.0 conforms to enum {1, 2} — must pass (was a false breach before).
313        let out = apply_contract(vec![json!({"rate": 1.0})], &c).unwrap();
314        assert_eq!(out.survivors.len(), 1);
315        assert!(out.quarantined.is_empty());
316        // A genuinely out-of-set value still breaches.
317        let err = apply_contract(vec![json!({"rate": 3.0})], &c).unwrap_err();
318        assert!(matches!(err, FaucetError::ContractViolation { .. }));
319    }
320
321    #[test]
322    fn fail_raises_typed_error_with_version_and_field() {
323        let page = vec![
324            json!({"id": 1, "status": "open"}),
325            json!({"id": 2, "status": "bogus"}),
326        ];
327        let err = apply_contract(page, &basic("fail")).unwrap_err();
328        match err {
329            FaucetError::ContractViolation { version, message } => {
330                assert_eq!(version, "1.0.0");
331                assert!(message.contains("status"), "message: {message}");
332                assert!(message.contains("enum"), "message: {message}");
333            }
334            other => panic!("expected ContractViolation, got {other:?}"),
335        }
336    }
337
338    #[test]
339    fn quarantine_partitions_page_and_keeps_page_index() {
340        let page = vec![
341            json!({"id": 1, "status": "open"}),
342            json!({"id": -5, "status": "open"}), // range breach at index 1
343            json!({"id": 3, "status": "open"}),
344            json!({"id": 4, "status": "nope"}), // enum breach at index 3
345        ];
346        let out = apply_contract(page, &basic("quarantine")).unwrap();
347        assert_eq!(out.survivors.len(), 2);
348        let idx: Vec<usize> = out
349            .quarantined
350            .iter()
351            .map(|q| q.violation.page_index)
352            .collect();
353        assert_eq!(idx, vec![1, 3], "page_index must be the original position");
354        assert_eq!(out.quarantined[0].violation.rule, "range");
355        assert_eq!(out.quarantined[1].violation.rule, "enum");
356    }
357
358    #[test]
359    fn warn_keeps_all_records_and_reports_breaches() {
360        let page = vec![
361            json!({"id": 1, "status": "open"}),
362            json!({"id": "not-an-int", "status": "open"}),
363        ];
364        let out = apply_contract(page.clone(), &basic("warn")).unwrap();
365        assert_eq!(out.survivors, page, "warn must not remove records");
366        assert!(out.quarantined.is_empty());
367        assert_eq!(out.warned.len(), 1);
368        assert_eq!(out.warned[0].rule, "type");
369        assert_eq!(out.warned[0].field.as_deref(), Some("id"));
370        assert_eq!(out.warned[0].page_index, 1);
371    }
372
373    #[test]
374    fn missing_required_field_breaches() {
375        let out = apply_contract(vec![json!({"status": "open"})], &basic("warn")).unwrap();
376        assert_eq!(out.warned[0].rule, "missing");
377        assert_eq!(out.warned[0].field.as_deref(), Some("id"));
378    }
379
380    #[test]
381    fn null_on_non_nullable_breaches() {
382        let out =
383            apply_contract(vec![json!({"id": null, "status": "open"})], &basic("warn")).unwrap();
384        assert_eq!(out.warned[0].rule, "null");
385    }
386
387    #[test]
388    fn pattern_and_length_breaches() {
389        let c = basic("warn");
390        let out = apply_contract(
391            vec![json!({"id": 1, "status": "open", "email": "nope"})],
392            &c,
393        )
394        .unwrap();
395        assert_eq!(out.warned[0].rule, "pattern");
396        let out =
397            apply_contract(vec![json!({"id": 1, "status": "open", "email": "@b"})], &c).unwrap();
398        // "@b" fails min_length 3? len 2 < 3 → but pattern checked first: "@b"
399        // does not match ^.+@.+$ either. Pattern wins (declared order of rules).
400        assert_eq!(out.warned[0].rule, "pattern");
401        let out =
402            apply_contract(vec![json!({"id": 1, "status": "open", "email": "a@c"})], &c).unwrap();
403        assert!(out.warned.is_empty(), "a@c matches pattern and min_length");
404    }
405
406    #[test]
407    fn min_length_breach_reported() {
408        let c = compiled(json!({
409            "version": "1", "on_breach": "warn",
410            "fields": [{ "name": "s", "type": "string", "min_length": 3, "max_length": 5 }]
411        }));
412        let out = apply_contract(vec![json!({"s": "ab"})], &c).unwrap();
413        assert_eq!(out.warned[0].rule, "length");
414        let out = apply_contract(vec![json!({"s": "abcdef"})], &c).unwrap();
415        assert_eq!(out.warned[0].rule, "length");
416        // char count, not byte count
417        let out = apply_contract(vec![json!({"s": "héllo"})], &c).unwrap();
418        assert!(out.warned.is_empty());
419    }
420
421    #[test]
422    fn integer_type_rejects_float_value() {
423        let out =
424            apply_contract(vec![json!({"id": 1.5, "status": "open"})], &basic("warn")).unwrap();
425        assert_eq!(out.warned[0].rule, "type");
426    }
427
428    #[test]
429    fn extra_field_breach_when_disallowed() {
430        let c = compiled(json!({
431            "version": "1", "on_breach": "warn", "allow_extra_fields": false,
432            "fields": [{ "name": "id", "type": "integer" }]
433        }));
434        let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
435        assert_eq!(out.warned[0].rule, "extra_field");
436        assert_eq!(out.warned[0].field.as_deref(), Some("surprise"));
437    }
438
439    #[test]
440    fn extra_field_allowed_by_default() {
441        let c = compiled(json!({
442            "version": "1", "on_breach": "warn",
443            "fields": [{ "name": "id", "type": "integer" }]
444        }));
445        let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
446        assert!(out.warned.is_empty());
447    }
448
449    #[test]
450    fn non_object_record_breaches() {
451        let out = apply_contract(vec![json!([1, 2, 3])], &basic("warn")).unwrap();
452        assert_eq!(out.warned[0].rule, "not_object");
453        assert!(out.warned[0].field.is_none());
454    }
455
456    #[test]
457    fn first_breach_wins_in_declared_field_order() {
458        // Both `id` (type) and `status` (enum) breach; `id` is declared first.
459        let out =
460            apply_contract(vec![json!({"id": "x", "status": "nope"})], &basic("warn")).unwrap();
461        assert_eq!(out.warned.len(), 1, "one breach per record");
462        assert_eq!(out.warned[0].field.as_deref(), Some("id"));
463    }
464
465    #[test]
466    fn range_bounds_inclusive() {
467        let c = compiled(json!({
468            "version": "1", "on_breach": "warn",
469            "fields": [{ "name": "n", "type": "number", "min": 0.0, "max": 10.0 }]
470        }));
471        let out = apply_contract(vec![json!({"n": 0.0}), json!({"n": 10.0})], &c).unwrap();
472        assert!(out.warned.is_empty(), "bounds are inclusive");
473        let out = apply_contract(vec![json!({"n": 10.001})], &c).unwrap();
474        assert_eq!(out.warned[0].rule, "range");
475    }
476
477    #[test]
478    fn violation_describe_includes_field_and_rule() {
479        let v = ContractViolation {
480            rule: "enum",
481            field: Some("status".into()),
482            message: "value \"x\" is not in the allowed set".into(),
483            page_index: 0,
484        };
485        let d = v.describe();
486        assert!(d.contains("field 'status'"));
487        assert!(d.contains("(enum)"));
488        let v2 = ContractViolation {
489            rule: "not_object",
490            field: None,
491            message: "record is not a JSON object (got array)".into(),
492            page_index: 0,
493        };
494        assert!(!v2.describe().contains("field"));
495    }
496}