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}