Skip to main content

apl_core/
pipeline.rs

1// Location: ./crates/apl-core/src/pipeline.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Pipe-chain IR for APL `args:` and `result:` phases.
7//
8// A field-level pipeline is a sequence of `Stage`s separated by `|` in the
9// DSL. Validators (str/int/range/...) check the field's value and can fail
10// the request; transforms (mask/redact/omit/hash) modify the value; effects
11// (taint) record side information.
12//
13// Grounded in apl-dsl-spec.md §4.
14//
15// Stages whose evaluator behavior is deferred to step 5c (taint dispatch,
16// plugin invocation, regex/named validators, scan placeholders) are still
17// represented in the IR so the parser can produce them — the evaluator
18// recognizes them and returns a clear "deferred" signal rather than crashing.
19
20use serde::{Deserialize, Serialize};
21
22use crate::rules::Expression;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum TypeCheck {
27    Str,
28    Int,
29    Bool,
30    Float,
31    Email,
32    Url,
33    Uuid,
34}
35
36/// Scope at which a taint applies. Marked `#[non_exhaustive]` so new
37/// variants (e.g. `Request`, `Pipeline`, conversation-level) can be
38/// added without breaking downstream exhaustive matches. v0 emits only
39/// `Session` and `Message`; plugin-extracted taints (from
40/// `extensions.security.labels` diffs in `CmfPluginInvoker`) default to
41/// `Session` because cpex-core's label monotonicity is session-semantic.
42/// Config-side `Step::Taint`/`Stage::Taint` declares scopes explicitly.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45#[non_exhaustive]
46pub enum TaintScope {
47    Session,
48    Message,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ScanKind {
54    PiiRedact,
55    PiiDetect,
56    InjectionScan,
57}
58
59/// One stage in a pipe chain.
60///
61/// Stages execute left-to-right against a single field value. Validators
62/// halt the pipeline on failure; transforms produce a new value; effects
63/// (taint) annotate without changing the value.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum Stage {
67    // ----- Validators (halt with deny on failure) -----
68    Type(TypeCheck),
69    /// `regex("pattern")` — parser captures the pattern; evaluator stubbed
70    /// until we add the `regex` crate dependency.
71    Regex {
72        pattern: String,
73    },
74    /// `validate(name)` — named validator dispatch; evaluator stubbed.
75    Validate {
76        name: String,
77    },
78    /// `len(..N)`, `len(N..M)`, `len(N..)` — string length bounds.
79    Length {
80        min: Option<usize>,
81        max: Option<usize>,
82    },
83    /// Bare range literal `N..M`, `..M`, `N..`, with optional `k`/`K`/`m`/`M`
84    /// numeric suffixes. Integer-only per DSL §4.3.
85    Range {
86        min: Option<i64>,
87        max: Option<i64>,
88    },
89    /// `enum(a, b, c)` — value must equal one of the listed strings.
90    Enum {
91        values: Vec<String>,
92    },
93
94    // ----- Transforms (produce a new value) -----
95    /// `mask(N)` — replace all but last N chars with `*`.
96    Mask {
97        keep_last: usize,
98    },
99    /// `redact` (unconditional) or `redact(!condition)` (conditional).
100    /// Replaces value with `[REDACTED]` when condition is true (or always,
101    /// if no condition).
102    Redact {
103        condition: Option<Expression>,
104    },
105    /// `omit` — drop the field from output entirely. No conditional form
106    /// per DSL §4.1 — use a policy rule for conditional omit.
107    Omit,
108    /// `hash` — replace value with a hash digest.
109    Hash,
110
111    // ----- Effects (deferred to step 5c — IR captured, eval stubbed) -----
112    Taint {
113        label: String,
114        scopes: Vec<TaintScope>,
115    },
116    Plugin {
117        name: String,
118    },
119    Scan {
120        kind: ScanKind,
121    },
122}
123
124/// Sequence of stages applied to one field's value.
125#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126pub struct Pipeline {
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub stages: Vec<Stage>,
129}
130
131impl Pipeline {
132    pub fn new() -> Self {
133        Self::default()
134    }
135    pub fn push(&mut self, stage: Stage) {
136        self.stages.push(stage);
137    }
138    pub fn is_empty(&self) -> bool {
139        self.stages.is_empty()
140    }
141}
142
143/// Attaches a pipeline to a specific field name in the args or result phase.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct FieldRule {
146    pub field: String,
147    pub pipeline: Pipeline,
148    /// Source location (e.g., `"get_compensation.result.ssn"`) for audit.
149    pub source: String,
150}
151
152/// A taint label produced as a side effect of running a pipeline.
153///
154/// The evaluator accumulates these in `PipelineEvaluation.taints`; the host
155/// (apl-cpex) drains them and writes to the actual SessionStore. Same shape
156/// as `Stage::Taint`'s fields, but lives at the evaluator boundary because
157/// it also carries taints emitted by plugin invocations and scan stages
158/// — not just literal `taint(...)` stages.
159#[derive(Debug, Clone, PartialEq)]
160pub struct TaintEvent {
161    pub label: String,
162    pub scopes: Vec<TaintScope>,
163}