Skip to main content

iac_forge/
pipeline.rs

1//! Typed representation pipelines: promotions and mutations with traceable provenance.
2//!
3//! A **pipeline** is a sequence of stages that transform a value while
4//! enforcing invariants at every step. Two stage kinds capture the two
5//! shapes of work that happen to software representations:
6//!
7//! - **Promotion** (`A → B`): cross a representation boundary. The value's
8//!   TYPE changes; the meaning is preserved and typically gains proofs.
9//!   Examples: `RawToml → IacResource`, `IacResource → Vec<GeneratedArtifact>`,
10//!   `IacType → RubyType`, `IacResource → Nix attribute set`.
11//!
12//! - **Mutation** (`A → A`): stay within a representation; change content
13//!   while preserving the type's invariants. Examples: applying a
14//!   `ResourceOp` script, running a Nix transform, normalising field
15//!   order.
16//!
17//! Both are morphisms. Promotions change the type parameter; mutations
18//! don't. The distinction matters for quality analysis: promotions are
19//! where new guarantees appear; mutations are where existing guarantees
20//! must be shown to hold after the change.
21//!
22//! # Quality selection
23//!
24//! A `Stage` declares what quality it **establishes** (after this stage
25//! runs, this property holds) and what qualities it **requires** (for
26//! this stage to be valid, these properties must already hold on the
27//! input). A pipeline executor can then validate at type-check time (or
28//! run-time, via [`Trace`]) that the quality contract holds end-to-end.
29//!
30//! # Trace output
31//!
32//! Running a pipeline produces a [`Trace`] — the full audit lineage of
33//! what happened at each stage. Each trace entry records:
34//! - Stage name
35//! - Input content hash
36//! - Output content hash
37//! - The quality established (if any)
38//! - Any invariant violations (empty if clean)
39//!
40//! The Trace round-trips through sexpr via [`ToSExpr`], so it's
41//! attestable, storable, and diffable like any other IR value.
42
43use crate::morphism::{Morphism, ProvenMorphism};
44use crate::sexpr::{
45    ContentHash, FromSExpr, SExpr, SExprError, ToSExpr, parse_struct, struct_expr, take_field,
46};
47
48// ── Quality tags ─────────────────────────────────────────────────
49
50/// A named quality a stage can require or establish.
51///
52/// Qualities are opaque strings by design: the set is extensible per-
53/// platform without a central registry. Conventional names:
54///
55/// - `"validated"` — input has passed schema/spec validation
56/// - `"compliance:nist-800-53"` — NIST compliance invariants hold
57/// - `"compliance:pci-dss"` — PCI DSS invariants hold
58/// - `"content-addressed"` — value has a stable BLAKE3 content hash
59/// - `"deterministic-render"` — rendering from this produces byte-
60///   identical output on re-run
61/// - `"attested"` — value carries a signed provenance chain
62#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd)]
63pub struct Quality(pub String);
64
65impl Quality {
66    pub fn new(name: impl Into<String>) -> Self {
67        Self(name.into())
68    }
69
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73}
74
75impl std::fmt::Display for Quality {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81// ── StageKind ────────────────────────────────────────────────────
82
83/// Kind of stage: promotion crosses a type boundary, mutation stays within one.
84///
85/// The distinction is informational — both are morphisms, and both run
86/// through `apply` + `check_invariants`. Surfacing the kind makes
87/// pipeline diagrams and audit reports clearer about what's happening
88/// at each step.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum StageKind {
91    /// `A → B`: value's type changes, meaning is preserved.
92    Promotion,
93    /// `A → A`: content changes, type (and its invariants) preserved.
94    Mutation,
95}
96
97impl std::fmt::Display for StageKind {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::Promotion => f.write_str("promotion"),
101            Self::Mutation => f.write_str("mutation"),
102        }
103    }
104}
105
106// ── Stage ────────────────────────────────────────────────────────
107
108/// A single step in a pipeline.
109///
110/// Uses a boxed trait object for the morphism so pipelines can hold
111/// heterogeneous stages in a single `Vec`.
112pub struct Stage<Src, Dst> {
113    name: String,
114    kind: StageKind,
115    requires: Vec<Quality>,
116    establishes: Vec<Quality>,
117    morphism: Box<dyn ProvenMorphism<Src, Dst>>,
118}
119
120impl<Src, Dst> Stage<Src, Dst> {
121    /// Construct a new stage with a boxed morphism.
122    pub fn new<M: ProvenMorphism<Src, Dst> + 'static>(
123        name: impl Into<String>,
124        kind: StageKind,
125        morphism: M,
126    ) -> Self {
127        Self {
128            name: name.into(),
129            kind,
130            requires: Vec::new(),
131            establishes: Vec::new(),
132            morphism: Box::new(morphism),
133        }
134    }
135
136    /// Add a required-input quality (builder).
137    #[must_use]
138    pub fn requires(mut self, q: Quality) -> Self {
139        self.requires.push(q);
140        self
141    }
142
143    /// Add an established-output quality (builder).
144    #[must_use]
145    pub fn establishes(mut self, q: Quality) -> Self {
146        self.establishes.push(q);
147        self
148    }
149
150    /// Read-only access.
151    pub fn name(&self) -> &str {
152        &self.name
153    }
154    pub fn kind(&self) -> StageKind {
155        self.kind
156    }
157    pub fn requires_qualities(&self) -> &[Quality] {
158        &self.requires
159    }
160    pub fn establishes_qualities(&self) -> &[Quality] {
161        &self.establishes
162    }
163}
164
165impl<Src, Dst> Stage<Src, Dst>
166where
167    Src: ToSExpr,
168    Dst: ToSExpr,
169{
170    /// Run the stage against an input, emitting a [`TraceStep`]
171    /// alongside the output (or recorded violations if the morphism
172    /// fails its invariant check).
173    pub fn run(
174        &self,
175        input: &Src,
176        held_qualities: &[Quality],
177    ) -> Result<(Dst, TraceStep), PipelineError> {
178        // Check pre-requisite qualities on input.
179        for req in &self.requires {
180            if !held_qualities.contains(req) {
181                return Err(PipelineError::MissingQuality {
182                    stage: self.name.clone(),
183                    quality: req.clone(),
184                });
185            }
186        }
187
188        let output = self.morphism.apply(input);
189        let input_hash = input.content_hash();
190        let output_hash = output.content_hash();
191        let violations = self.morphism.check_invariants(input, &output);
192
193        let step = TraceStep {
194            stage_name: self.name.clone(),
195            morphism_name: self.morphism.name().to_string(),
196            kind: self.kind,
197            input_hash,
198            output_hash,
199            established: self.establishes.clone(),
200            violations: violations.clone(),
201        };
202
203        if !violations.is_empty() {
204            return Err(PipelineError::InvariantViolations {
205                stage: self.name.clone(),
206                violations,
207            });
208        }
209
210        Ok((output, step))
211    }
212}
213
214// ── Trace ────────────────────────────────────────────────────────
215
216/// Per-stage record produced during pipeline execution.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct TraceStep {
219    pub stage_name: String,
220    pub morphism_name: String,
221    pub kind: StageKind,
222    pub input_hash: ContentHash,
223    pub output_hash: ContentHash,
224    pub established: Vec<Quality>,
225    pub violations: Vec<String>,
226}
227
228impl TraceStep {
229    pub fn is_clean(&self) -> bool {
230        self.violations.is_empty()
231    }
232}
233
234impl ToSExpr for TraceStep {
235    fn to_sexpr(&self) -> SExpr {
236        struct_expr(
237            "trace-step",
238            vec![
239                ("stage", self.stage_name.to_sexpr()),
240                ("morphism", self.morphism_name.to_sexpr()),
241                ("kind", SExpr::Symbol(format!("{}", self.kind))),
242                ("input-hash", SExpr::String(self.input_hash.to_hex())),
243                ("output-hash", SExpr::String(self.output_hash.to_hex())),
244                (
245                    "established",
246                    SExpr::List({
247                        let mut v = vec![SExpr::Symbol("list".into())];
248                        v.extend(self.established.iter().map(|q| SExpr::String(q.0.clone())));
249                        v
250                    }),
251                ),
252                (
253                    "violations",
254                    SExpr::List({
255                        let mut v = vec![SExpr::Symbol("list".into())];
256                        v.extend(self.violations.iter().map(|s| SExpr::String(s.clone())));
257                        v
258                    }),
259                ),
260            ],
261        )
262    }
263}
264
265impl FromSExpr for TraceStep {
266    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
267        let f = parse_struct(s, "trace-step")?;
268        let kind_sym = take_field(&f, "kind")?.as_symbol()?;
269        let kind = match kind_sym {
270            "promotion" => StageKind::Promotion,
271            "mutation" => StageKind::Mutation,
272            other => return Err(SExprError::UnknownVariant(format!("StageKind::{other}"))),
273        };
274        let input_hex = String::from_sexpr(take_field(&f, "input-hash")?)?;
275        let output_hex = String::from_sexpr(take_field(&f, "output-hash")?)?;
276        Ok(Self {
277            stage_name: String::from_sexpr(take_field(&f, "stage")?)?,
278            morphism_name: String::from_sexpr(take_field(&f, "morphism")?)?,
279            kind,
280            input_hash: hex_to_hash(&input_hex)?,
281            output_hash: hex_to_hash(&output_hex)?,
282            established: Vec::<String>::from_sexpr(take_field(&f, "established")?)?
283                .into_iter()
284                .map(Quality::new)
285                .collect(),
286            violations: Vec::<String>::from_sexpr(take_field(&f, "violations")?)?,
287        })
288    }
289}
290
291fn hex_to_hash(hex: &str) -> Result<ContentHash, SExprError> {
292    if hex.len() != 64 {
293        return Err(SExprError::Shape(format!(
294            "content hash must be 64 hex chars, got {}",
295            hex.len()
296        )));
297    }
298    let mut bytes = [0u8; 32];
299    for (i, pair) in hex.as_bytes().chunks(2).enumerate() {
300        let s = std::str::from_utf8(pair)
301            .map_err(|_| SExprError::Parse("non-ascii in hash hex".into()))?;
302        bytes[i] = u8::from_str_radix(s, 16)
303            .map_err(|e| SExprError::Parse(format!("bad hex byte: {e}")))?;
304    }
305    Ok(ContentHash(bytes))
306}
307
308/// Complete execution trace of a pipeline run.
309#[derive(Debug, Clone, Default, PartialEq, Eq)]
310pub struct Trace {
311    pub steps: Vec<TraceStep>,
312}
313
314impl Trace {
315    pub fn new() -> Self {
316        Self::default()
317    }
318
319    pub fn push(&mut self, step: TraceStep) {
320        self.steps.push(step);
321    }
322
323    pub fn is_empty(&self) -> bool {
324        self.steps.is_empty()
325    }
326
327    pub fn len(&self) -> usize {
328        self.steps.len()
329    }
330
331    /// Are all steps clean (no violations)?
332    pub fn is_clean(&self) -> bool {
333        self.steps.iter().all(TraceStep::is_clean)
334    }
335
336    /// All qualities established end-to-end.
337    pub fn established(&self) -> Vec<Quality> {
338        let mut out: Vec<Quality> = Vec::new();
339        for step in &self.steps {
340            for q in &step.established {
341                if !out.contains(q) {
342                    out.push(q.clone());
343                }
344            }
345        }
346        out.sort();
347        out
348    }
349}
350
351impl ToSExpr for Trace {
352    fn to_sexpr(&self) -> SExpr {
353        struct_expr("trace", vec![("steps", self.steps.to_sexpr())])
354    }
355}
356
357impl FromSExpr for Trace {
358    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
359        let f = parse_struct(s, "trace")?;
360        Ok(Self {
361            steps: Vec::<TraceStep>::from_sexpr(take_field(&f, "steps")?)?,
362        })
363    }
364}
365
366// ── Errors ───────────────────────────────────────────────────────
367
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub enum PipelineError {
370    MissingQuality {
371        stage: String,
372        quality: Quality,
373    },
374    InvariantViolations {
375        stage: String,
376        violations: Vec<String>,
377    },
378}
379
380impl std::fmt::Display for PipelineError {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        match self {
383            Self::MissingQuality { stage, quality } => write!(
384                f,
385                "stage '{stage}' requires quality '{quality}' which is not established"
386            ),
387            Self::InvariantViolations { stage, violations } => write!(
388                f,
389                "stage '{stage}' produced invariant violations: {violations:?}"
390            ),
391        }
392    }
393}
394
395impl std::error::Error for PipelineError {}
396
397// ── Convenience: single-type mutation pipeline ───────────────────
398
399/// Run a chain of same-type stages against an initial value.
400///
401/// All stages share `T` as both input and output (mutation stages).
402/// Each stage's `run` produces a new value and a `TraceStep`; the
403/// returned `Trace` records the full lineage. On first error, the
404/// pipeline halts with a partial trace.
405pub fn run_mutation_chain<T>(
406    start: T,
407    stages: Vec<Stage<T, T>>,
408    initial_qualities: &[Quality],
409) -> Result<(T, Trace), (PipelineError, Trace)>
410where
411    T: ToSExpr + Clone,
412{
413    let mut current = start;
414    let mut trace = Trace::new();
415    let mut held: Vec<Quality> = initial_qualities.to_vec();
416
417    for stage in stages {
418        match stage.run(&current, &held) {
419            Ok((output, step)) => {
420                for q in &step.established {
421                    if !held.contains(q) {
422                        held.push(q.clone());
423                    }
424                }
425                trace.push(step);
426                current = output;
427            }
428            Err(e) => return Err((e, trace)),
429        }
430    }
431
432    Ok((current, trace))
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::morphism::{Morphism, ProvenMorphism};
439
440    // ── Concrete test morphisms ────────────────────────────────
441
442    struct AddOne;
443    impl Morphism<i64, i64> for AddOne {
444        fn name(&self) -> &'static str {
445            "AddOne"
446        }
447        fn apply(&self, src: &i64) -> i64 {
448            src + 1
449        }
450    }
451    impl ProvenMorphism<i64, i64> for AddOne {
452        fn check_invariants(&self, src: &i64, dst: &i64) -> Vec<String> {
453            if *dst == src + 1 {
454                Vec::new()
455            } else {
456                vec!["AddOne: dst != src + 1".into()]
457            }
458        }
459    }
460
461    struct Double;
462    impl Morphism<i64, i64> for Double {
463        fn name(&self) -> &'static str {
464            "Double"
465        }
466        fn apply(&self, src: &i64) -> i64 {
467            src * 2
468        }
469    }
470    impl ProvenMorphism<i64, i64> for Double {
471        fn check_invariants(&self, src: &i64, dst: &i64) -> Vec<String> {
472            if *dst == src * 2 {
473                Vec::new()
474            } else {
475                vec!["Double: dst != 2 * src".into()]
476            }
477        }
478    }
479
480    // ── Stage ─────────────────────────────────────────────────
481
482    #[test]
483    fn stage_records_hashes_on_clean_run() {
484        let stage = Stage::new("add-one", StageKind::Mutation, AddOne);
485        let (out, step) = stage.run(&5, &[]).unwrap();
486        assert_eq!(out, 6);
487        assert_eq!(step.input_hash, 5i64.content_hash());
488        assert_eq!(step.output_hash, 6i64.content_hash());
489        assert!(step.is_clean());
490        assert_eq!(step.kind, StageKind::Mutation);
491    }
492
493    #[test]
494    fn stage_requires_quality_present_in_held() {
495        let stage =
496            Stage::new("x", StageKind::Mutation, AddOne).requires(Quality::new("validated"));
497        let err = stage.run(&5, &[]).unwrap_err();
498        assert!(matches!(err, PipelineError::MissingQuality { .. }));
499
500        let ok = stage.run(&5, &[Quality::new("validated")]);
501        assert!(ok.is_ok());
502    }
503
504    #[test]
505    fn stage_establishes_quality_recorded_in_step() {
506        let stage =
507            Stage::new("x", StageKind::Mutation, AddOne).establishes(Quality::new("normalized"));
508        let (_, step) = stage.run(&5, &[]).unwrap();
509        assert_eq!(step.established, vec![Quality::new("normalized")]);
510    }
511
512    // ── run_mutation_chain ────────────────────────────────────
513
514    #[test]
515    fn mutation_chain_runs_in_order() {
516        let stages = vec![
517            Stage::new("s1", StageKind::Mutation, AddOne),
518            Stage::new("s2", StageKind::Mutation, Double),
519        ];
520        let (out, trace) = run_mutation_chain(5_i64, stages, &[]).unwrap();
521        assert_eq!(out, 12); // (5+1)*2
522        assert_eq!(trace.len(), 2);
523        assert!(trace.is_clean());
524    }
525
526    #[test]
527    fn mutation_chain_propagates_established_qualities() {
528        let stages = vec![
529            Stage::new("promote", StageKind::Mutation, AddOne)
530                .establishes(Quality::new("normalized")),
531            Stage::new("then-requires", StageKind::Mutation, Double)
532                .requires(Quality::new("normalized")),
533        ];
534        let result = run_mutation_chain(1_i64, stages, &[]);
535        assert!(result.is_ok(), "qualities should propagate: {result:?}");
536    }
537
538    #[test]
539    fn mutation_chain_halts_on_missing_quality() {
540        let stages = vec![
541            Stage::new("needs-validated", StageKind::Mutation, AddOne)
542                .requires(Quality::new("validated")),
543        ];
544        let (err, partial) = run_mutation_chain(1_i64, stages, &[]).unwrap_err();
545        assert!(matches!(err, PipelineError::MissingQuality { .. }));
546        assert!(partial.is_empty(), "no steps should have run");
547    }
548
549    // ── Invariant violation ───────────────────────────────────
550
551    struct BadDouble;
552    impl Morphism<i64, i64> for BadDouble {
553        fn name(&self) -> &'static str {
554            "BadDouble"
555        }
556        fn apply(&self, src: &i64) -> i64 {
557            src * 3 // wrong
558        }
559    }
560    impl ProvenMorphism<i64, i64> for BadDouble {
561        fn check_invariants(&self, src: &i64, dst: &i64) -> Vec<String> {
562            if *dst == src * 2 {
563                Vec::new()
564            } else {
565                vec!["Double: dst != 2 * src".into()]
566            }
567        }
568    }
569
570    #[test]
571    fn mutation_chain_halts_on_invariant_violation() {
572        let stages = vec![
573            Stage::new("s1", StageKind::Mutation, AddOne),
574            Stage::new("bad", StageKind::Mutation, BadDouble),
575            Stage::new("s3", StageKind::Mutation, AddOne),
576        ];
577        let (err, partial) = run_mutation_chain(5_i64, stages, &[]).unwrap_err();
578        assert!(matches!(err, PipelineError::InvariantViolations { .. }));
579        // Only s1 completed before bad tripped.
580        assert_eq!(partial.len(), 1);
581        assert_eq!(partial.steps[0].stage_name, "s1");
582    }
583
584    // ── Trace sexpr round-trip ────────────────────────────────
585
586    #[test]
587    fn trace_step_round_trips_via_sexpr() {
588        let stage = Stage::new("x", StageKind::Mutation, AddOne).establishes(Quality::new("q1"));
589        let (_, step) = stage.run(&5, &[]).unwrap();
590        let round = TraceStep::from_sexpr(&step.to_sexpr()).expect("parse");
591        assert_eq!(round, step);
592    }
593
594    #[test]
595    fn trace_round_trips_via_sexpr() {
596        let stages = vec![
597            Stage::new("a", StageKind::Mutation, AddOne),
598            Stage::new("b", StageKind::Mutation, Double),
599        ];
600        let (_, trace) = run_mutation_chain(1_i64, stages, &[]).unwrap();
601        let round = Trace::from_sexpr(&trace.to_sexpr()).expect("parse");
602        assert_eq!(round, trace);
603    }
604
605    // ── Content addressing ───────────────────────────────────
606
607    #[test]
608    fn trace_content_hash_is_deterministic() {
609        let make = || {
610            let stages = vec![
611                Stage::new("a", StageKind::Mutation, AddOne),
612                Stage::new("b", StageKind::Mutation, Double),
613            ];
614            run_mutation_chain(1_i64, stages, &[]).unwrap().1
615        };
616        assert_eq!(make().content_hash(), make().content_hash());
617    }
618
619    #[test]
620    fn trace_hash_differs_for_different_inputs() {
621        let stages1 = vec![Stage::new("a", StageKind::Mutation, AddOne)];
622        let stages2 = vec![Stage::new("a", StageKind::Mutation, AddOne)];
623        let t1 = run_mutation_chain(1_i64, stages1, &[]).unwrap().1;
624        let t2 = run_mutation_chain(99_i64, stages2, &[]).unwrap().1;
625        assert_ne!(t1.content_hash(), t2.content_hash());
626    }
627
628    // ── Established qualities aggregation ────────────────────
629
630    #[test]
631    fn trace_aggregates_all_established_qualities() {
632        let stages = vec![
633            Stage::new("a", StageKind::Mutation, AddOne).establishes(Quality::new("q1")),
634            Stage::new("b", StageKind::Mutation, Double)
635                .establishes(Quality::new("q2"))
636                .establishes(Quality::new("q1")), // duplicate
637        ];
638        let (_, trace) = run_mutation_chain(1_i64, stages, &[]).unwrap();
639        let qs = trace.established();
640        assert_eq!(qs.len(), 2);
641        assert!(qs.contains(&Quality::new("q1")));
642        assert!(qs.contains(&Quality::new("q2")));
643    }
644
645    // ── Stage-kind surfacing in trace ────────────────────────
646
647    #[test]
648    fn trace_step_preserves_stage_kind() {
649        let m = Stage::new("m", StageKind::Mutation, AddOne);
650        let p = Stage::new("p", StageKind::Promotion, AddOne);
651        let (_, step_m) = m.run(&1, &[]).unwrap();
652        let (_, step_p) = p.run(&1, &[]).unwrap();
653        assert_eq!(step_m.kind, StageKind::Mutation);
654        assert_eq!(step_p.kind, StageKind::Promotion);
655    }
656}