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                "value is not in the allowed set".into(),
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 does not match pattern '{re}'"),
173                ));
174            }
175        }
176        if (f.min.is_some() || f.max.is_some())
177            && let Value::Number(n) = value
178        {
179            if let Some(min) = f.min
180                && cmp_number_to_bound(n, min) == Some(std::cmp::Ordering::Less)
181            {
182                return Some(violation(
183                    "range",
184                    Some(&f.name),
185                    format!("value is below the minimum {min}"),
186                ));
187            }
188            if let Some(max) = f.max
189                && cmp_number_to_bound(n, max) == Some(std::cmp::Ordering::Greater)
190            {
191                return Some(violation(
192                    "range",
193                    Some(&f.name),
194                    format!("value 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
236/// Order a JSON number against an `f64` bound without rounding an integer
237/// operand above 2^53 through `f64` (CORE-86).
238fn cmp_number_to_bound(n: &serde_json::Number, bound: f64) -> Option<std::cmp::Ordering> {
239    let int = n
240        .as_i64()
241        .map(i128::from)
242        .or_else(|| n.as_u64().map(i128::from));
243    match int {
244        Some(i) => cmp_int_to_f64(i, bound),
245        None => n.as_f64()?.partial_cmp(&bound),
246    }
247}
248
249fn cmp_int_to_f64(i: i128, bound: f64) -> Option<std::cmp::Ordering> {
250    use std::cmp::Ordering;
251    if bound.is_nan() {
252        return None;
253    }
254    let floor = bound.floor();
255    if floor >= 1.0e20 {
256        return Some(Ordering::Less);
257    }
258    if floor <= -1.0e20 {
259        return Some(Ordering::Greater);
260    }
261    match i.cmp(&(floor as i128)) {
262        Ordering::Equal if bound > floor => Some(Ordering::Less),
263        other => Some(other),
264    }
265}
266
267fn json_type_name(v: &Value) -> &'static str {
268    match v {
269        Value::Null => "null",
270        Value::Bool(_) => "boolean",
271        Value::Number(_) => "number",
272        Value::String(_) => "string",
273        Value::Array(_) => "array",
274        Value::Object(_) => "object",
275    }
276}
277
278/// Enum membership with numeric-aware equality: two JSON numbers compare by
279/// value (so an integer-spelled enum member `1` matches a conforming `1.0` on a
280/// `number` field), everything else by exact structural equality. `compile`
281/// already accepts an integer enum on a number field, and the per-record type
282/// check runs before this, so no cross-type coercion sneaks in (audit #321 M2).
283fn enum_contains(allowed: &[Value], value: &Value) -> bool {
284    allowed.iter().any(|v| match (v, value) {
285        (Value::Number(a), Value::Number(b)) => {
286            if let (Some(x), Some(y)) = (a.as_i64(), b.as_i64()) {
287                x == y
288            } else if let (Some(x), Some(y)) = (a.as_u64(), b.as_u64()) {
289                x == y
290            } else {
291                matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
292            }
293        }
294        _ => v == value,
295    })
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use serde_json::json;
302
303    fn compiled(v: Value) -> CompiledContract {
304        let spec: ContractSpec = serde_json::from_value(v).unwrap();
305        CompiledContract::compile(&spec).unwrap()
306    }
307
308    fn basic(on_breach: &str) -> CompiledContract {
309        compiled(json!({
310            "version": "1.0.0",
311            "on_breach": on_breach,
312            "fields": [
313                { "name": "id", "type": "integer", "min": 0 },
314                { "name": "status", "type": "string", "enum": ["open", "closed"] },
315                { "name": "email", "type": "string", "required": false,
316                  "nullable": true, "pattern": "^.+@.+$", "min_length": 3 }
317            ]
318        }))
319    }
320
321    #[test]
322    fn breach_messages_never_carry_the_value() {
323        let c = compiled(json!({
324            "version": "1.0.0",
325            "on_breach": "fail",
326            "fields": [
327                { "name": "s", "type": "string", "required": false, "enum": ["a"] },
328                { "name": "p", "type": "string", "required": false, "pattern": "^x$" },
329                { "name": "n", "type": "integer", "required": false, "min": 0, "max": 5 }
330            ]
331        }));
332        for rec in [
333            json!({"s": "secret-enum"}),
334            json!({"p": "secret-pattern"}),
335            json!({"n": 987654321}),
336            json!({"n": -987654321}),
337        ] {
338            let err = apply_contract(vec![rec], &c).unwrap_err().to_string();
339            assert!(
340                !err.contains("secret") && !err.contains("987654321"),
341                "{err}"
342            );
343        }
344    }
345
346    #[test]
347    fn integer_accepts_whole_floats_and_bounds_compare_exactly() {
348        let c = compiled(json!({
349            "version": "1.0.0",
350            "on_breach": "fail",
351            "fields": [ { "name": "n", "type": "integer", "max": 9007199254740992.0 } ]
352        }));
353        assert!(apply_contract(vec![json!({"n": 1.0})], &c).is_ok());
354        assert!(apply_contract(vec![json!({"n": 1.5})], &c).is_err());
355        assert!(apply_contract(vec![json!({"n": 9007199254740992u64})], &c).is_ok());
356        // 2^53 + 1 rounds to 2^53 through f64 and used to pass.
357        assert!(apply_contract(vec![json!({"n": 9007199254740993u64})], &c).is_err());
358    }
359
360    #[test]
361    fn int_to_f64_bound_ordering_edges() {
362        use std::cmp::Ordering;
363        assert_eq!(cmp_int_to_f64(1, 1.5), Some(Ordering::Less));
364        assert_eq!(cmp_int_to_f64(2, 1.5), Some(Ordering::Greater));
365        assert_eq!(cmp_int_to_f64(1, 1.0), Some(Ordering::Equal));
366        assert_eq!(cmp_int_to_f64(1, f64::NAN), None);
367        assert_eq!(
368            cmp_int_to_f64(i128::from(u64::MAX), 1e30),
369            Some(Ordering::Less)
370        );
371        assert_eq!(
372            cmp_int_to_f64(i128::from(i64::MIN), -1e30),
373            Some(Ordering::Greater)
374        );
375        assert_eq!(
376            cmp_number_to_bound(&serde_json::Number::from_f64(0.5).unwrap(), 1.0),
377            Some(Ordering::Less)
378        );
379    }
380
381    #[test]
382    fn conforming_page_passes_untouched() {
383        let page = vec![
384            json!({"id": 1, "status": "open", "email": "a@b.c"}),
385            json!({"id": 2, "status": "closed"}), // optional email absent
386            json!({"id": 3, "status": "open", "email": null}), // nullable
387        ];
388        let out = apply_contract(page.clone(), &basic("fail")).unwrap();
389        assert_eq!(out.survivors, page);
390        assert!(out.quarantined.is_empty());
391        assert!(out.warned.is_empty());
392    }
393
394    #[test]
395    fn numeric_enum_matches_across_int_float_spelling() {
396        // #321 M2: an integer-spelled enum on a `number` field must accept a
397        // conforming float value (and vice-versa) instead of spuriously breaching.
398        let c = compiled(json!({
399            "version": "1.0.0",
400            "on_breach": "fail",
401            "fields": [ { "name": "rate", "type": "number", "enum": [1, 2] } ]
402        }));
403        // 1.0 conforms to enum {1, 2} — must pass (was a false breach before).
404        let out = apply_contract(vec![json!({"rate": 1.0})], &c).unwrap();
405        assert_eq!(out.survivors.len(), 1);
406        assert!(out.quarantined.is_empty());
407        // A genuinely out-of-set value still breaches.
408        let err = apply_contract(vec![json!({"rate": 3.0})], &c).unwrap_err();
409        assert!(matches!(err, FaucetError::ContractViolation { .. }));
410    }
411
412    #[test]
413    fn fail_raises_typed_error_with_version_and_field() {
414        let page = vec![
415            json!({"id": 1, "status": "open"}),
416            json!({"id": 2, "status": "bogus"}),
417        ];
418        let err = apply_contract(page, &basic("fail")).unwrap_err();
419        match err {
420            FaucetError::ContractViolation { version, message } => {
421                assert_eq!(version, "1.0.0");
422                assert!(message.contains("status"), "message: {message}");
423                assert!(message.contains("enum"), "message: {message}");
424            }
425            other => panic!("expected ContractViolation, got {other:?}"),
426        }
427    }
428
429    #[test]
430    fn quarantine_partitions_page_and_keeps_page_index() {
431        let page = vec![
432            json!({"id": 1, "status": "open"}),
433            json!({"id": -5, "status": "open"}), // range breach at index 1
434            json!({"id": 3, "status": "open"}),
435            json!({"id": 4, "status": "nope"}), // enum breach at index 3
436        ];
437        let out = apply_contract(page, &basic("quarantine")).unwrap();
438        assert_eq!(out.survivors.len(), 2);
439        let idx: Vec<usize> = out
440            .quarantined
441            .iter()
442            .map(|q| q.violation.page_index)
443            .collect();
444        assert_eq!(idx, vec![1, 3], "page_index must be the original position");
445        assert_eq!(out.quarantined[0].violation.rule, "range");
446        assert_eq!(out.quarantined[1].violation.rule, "enum");
447    }
448
449    #[test]
450    fn warn_keeps_all_records_and_reports_breaches() {
451        let page = vec![
452            json!({"id": 1, "status": "open"}),
453            json!({"id": "not-an-int", "status": "open"}),
454        ];
455        let out = apply_contract(page.clone(), &basic("warn")).unwrap();
456        assert_eq!(out.survivors, page, "warn must not remove records");
457        assert!(out.quarantined.is_empty());
458        assert_eq!(out.warned.len(), 1);
459        assert_eq!(out.warned[0].rule, "type");
460        assert_eq!(out.warned[0].field.as_deref(), Some("id"));
461        assert_eq!(out.warned[0].page_index, 1);
462    }
463
464    #[test]
465    fn missing_required_field_breaches() {
466        let out = apply_contract(vec![json!({"status": "open"})], &basic("warn")).unwrap();
467        assert_eq!(out.warned[0].rule, "missing");
468        assert_eq!(out.warned[0].field.as_deref(), Some("id"));
469    }
470
471    #[test]
472    fn null_on_non_nullable_breaches() {
473        let out =
474            apply_contract(vec![json!({"id": null, "status": "open"})], &basic("warn")).unwrap();
475        assert_eq!(out.warned[0].rule, "null");
476    }
477
478    #[test]
479    fn pattern_and_length_breaches() {
480        let c = basic("warn");
481        let out = apply_contract(
482            vec![json!({"id": 1, "status": "open", "email": "nope"})],
483            &c,
484        )
485        .unwrap();
486        assert_eq!(out.warned[0].rule, "pattern");
487        let out =
488            apply_contract(vec![json!({"id": 1, "status": "open", "email": "@b"})], &c).unwrap();
489        // "@b" fails min_length 3? len 2 < 3 → but pattern checked first: "@b"
490        // does not match ^.+@.+$ either. Pattern wins (declared order of rules).
491        assert_eq!(out.warned[0].rule, "pattern");
492        let out =
493            apply_contract(vec![json!({"id": 1, "status": "open", "email": "a@c"})], &c).unwrap();
494        assert!(out.warned.is_empty(), "a@c matches pattern and min_length");
495    }
496
497    #[test]
498    fn min_length_breach_reported() {
499        let c = compiled(json!({
500            "version": "1", "on_breach": "warn",
501            "fields": [{ "name": "s", "type": "string", "min_length": 3, "max_length": 5 }]
502        }));
503        let out = apply_contract(vec![json!({"s": "ab"})], &c).unwrap();
504        assert_eq!(out.warned[0].rule, "length");
505        let out = apply_contract(vec![json!({"s": "abcdef"})], &c).unwrap();
506        assert_eq!(out.warned[0].rule, "length");
507        // char count, not byte count
508        let out = apply_contract(vec![json!({"s": "héllo"})], &c).unwrap();
509        assert!(out.warned.is_empty());
510    }
511
512    #[test]
513    fn integer_type_rejects_float_value() {
514        let out =
515            apply_contract(vec![json!({"id": 1.5, "status": "open"})], &basic("warn")).unwrap();
516        assert_eq!(out.warned[0].rule, "type");
517    }
518
519    #[test]
520    fn extra_field_breach_when_disallowed() {
521        let c = compiled(json!({
522            "version": "1", "on_breach": "warn", "allow_extra_fields": false,
523            "fields": [{ "name": "id", "type": "integer" }]
524        }));
525        let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
526        assert_eq!(out.warned[0].rule, "extra_field");
527        assert_eq!(out.warned[0].field.as_deref(), Some("surprise"));
528    }
529
530    #[test]
531    fn extra_field_allowed_by_default() {
532        let c = compiled(json!({
533            "version": "1", "on_breach": "warn",
534            "fields": [{ "name": "id", "type": "integer" }]
535        }));
536        let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
537        assert!(out.warned.is_empty());
538    }
539
540    #[test]
541    fn non_object_record_breaches() {
542        let out = apply_contract(vec![json!([1, 2, 3])], &basic("warn")).unwrap();
543        assert_eq!(out.warned[0].rule, "not_object");
544        assert!(out.warned[0].field.is_none());
545    }
546
547    #[test]
548    fn first_breach_wins_in_declared_field_order() {
549        // Both `id` (type) and `status` (enum) breach; `id` is declared first.
550        let out =
551            apply_contract(vec![json!({"id": "x", "status": "nope"})], &basic("warn")).unwrap();
552        assert_eq!(out.warned.len(), 1, "one breach per record");
553        assert_eq!(out.warned[0].field.as_deref(), Some("id"));
554    }
555
556    #[test]
557    fn range_bounds_inclusive() {
558        let c = compiled(json!({
559            "version": "1", "on_breach": "warn",
560            "fields": [{ "name": "n", "type": "number", "min": 0.0, "max": 10.0 }]
561        }));
562        let out = apply_contract(vec![json!({"n": 0.0}), json!({"n": 10.0})], &c).unwrap();
563        assert!(out.warned.is_empty(), "bounds are inclusive");
564        let out = apply_contract(vec![json!({"n": 10.001})], &c).unwrap();
565        assert_eq!(out.warned[0].rule, "range");
566    }
567
568    #[test]
569    fn violation_describe_includes_field_and_rule() {
570        let v = ContractViolation {
571            rule: "enum",
572            field: Some("status".into()),
573            message: "value \"x\" is not in the allowed set".into(),
574            page_index: 0,
575        };
576        let d = v.describe();
577        assert!(d.contains("field 'status'"));
578        assert!(d.contains("(enum)"));
579        let v2 = ContractViolation {
580            rule: "not_object",
581            field: None,
582            message: "record is not a JSON object (got array)".into(),
583            page_index: 0,
584        };
585        assert!(!v2.describe().contains("field"));
586    }
587}