Skip to main content

faucet_cli/
expand.rs

1//! Expand a parsed [`PipelineConfig`] into a flat list of [`ExpandedNode`]s
2//! ready for the executor to run.
3//!
4//! Responsibilities:
5//! - Assign synthetic ids to anonymous rows (`row-0`, `row-1`, …).
6//! - Reject reserved row ids (`env`, `file`, `secret`, `matrix`, `pipeline`).
7//! - Reject duplicate ids.
8//! - Validate that every `parent:` references a known row id and that the
9//!   parent chain has no cycles.
10//! - Deep-merge each row's partial overrides into `pipeline.*`.
11//! - Find every `${id.path}` token surviving from load-time interpolation and
12//!   record where each one came from. Tokens that reference unknown ids
13//!   produce a `CliError::UnknownInterpolationId` here, not at runtime.
14
15use crate::config::{
16    ConnectorSpec, MatrixRow, PartialConnector, PipelineConfig, PipelineSpec, StateStoreSpec,
17    TransformSpec,
18};
19use crate::error::{CliError, CliResult};
20use crate::interpolate::{Directive, iter_directives};
21use crate::merge::merge_value;
22use serde_json::Value;
23use std::collections::{BTreeSet, HashMap, HashSet};
24
25/// Row ids that callers can never use because they collide with
26/// load-time interpolation prefixes or future runtime scopes.
27pub const RESERVED_IDS: &[&str] = &[
28    "env",
29    "file",
30    "secret",
31    "matrix",
32    "pipeline",
33    "now",
34    "backfill",
35    "param",
36    "partition",
37    "bookmark",
38    "job_id",
39    "window",
40];
41
42/// One fully-merged matrix row, ready for the executor.
43#[derive(Debug, Clone)]
44pub struct ExpandedNode {
45    pub id: String,
46    pub row_index: usize,
47    pub role: NodeRole,
48    pub source: ConnectorSpec,
49    pub sink: ConnectorSpec,
50    pub transforms: Vec<TransformSpec>,
51    pub state: Option<StateStoreSpec>,
52    /// Resolved DLQ spec for this row, or `None` if no DLQ applies.
53    pub dlq: Option<crate::config::DlqSpec>,
54    /// Pipeline-level quality spec, shared by every node. `quality:` has no
55    /// matrix-row override in v1, so this is `cfg.pipeline.quality` verbatim.
56    #[cfg(feature = "quality")]
57    pub quality: Option<faucet_core::QualitySpec>,
58    /// Pipeline-level data contract, shared by every node (`contract:` has no
59    /// matrix-row override in v1) — `cfg.pipeline.contract` verbatim.
60    #[cfg(feature = "contract")]
61    pub contract: Option<faucet_core::ContractSpec>,
62    /// Pipeline-level PII masking policy, shared by every node (`masking:` has
63    /// no matrix-row override in v1) — `cfg.pipeline.masking` verbatim. The
64    /// executor compiles it *scoped to this node's sink* ([`sink_ref`] +
65    /// [`sink`].`kind`) so `applies_to` destination-scoping works (#206).
66    ///
67    /// [`sink_ref`]: ExpandedNode::sink_ref
68    /// [`sink`]: ExpandedNode::sink
69    #[cfg(feature = "masking")]
70    pub masking: Option<faucet_core::MaskingSpec>,
71    /// The sink template name this node resolved (`sink.ref`, or `"default"`
72    /// for the legacy singular `pipeline.sink`). Used to scope masking
73    /// `applies_to` rules per destination.
74    pub sink_ref: String,
75    /// Compiled schema-drift policy spec (pipeline-level; same for every node).
76    pub schema: Option<faucet_core::SchemaDriftSpec>,
77    /// Delivery guarantee for this row. Resolved from the row's override or
78    /// falls back to the top-level `cfg.delivery`.
79    pub delivery: faucet_core::DeliveryMode,
80    /// The **derived** end-to-end guarantee this row's source × sink × config
81    /// actually provides (issue #292) — computed for every row regardless of
82    /// the requested `delivery:` mode, so `faucet validate` / `doctor` report
83    /// it truthfully (e.g. a keyed-upsert row is effectively-once even when
84    /// the user did not ask for `exactly_once`).
85    pub delivery_guarantee: faucet_core::DeliveryGuarantee,
86    /// Row ids this node waits for (deduplicated, declaration order). The
87    /// executor starts the node only after every listed row's invocations
88    /// finish successfully; a failed or skipped dependency skips this node.
89    pub depends_on: Vec<String>,
90    /// Resolved readiness status for this row's source (#371) — the source
91    /// template's `status` overridden by the row's `source.status`, defaulting
92    /// to [`SourceStatus::Active`] when neither is set. Drives the runtime
93    /// run-set status gate; does not affect the state key.
94    ///
95    /// [`SourceStatus::Active`]: crate::config::SourceStatus::Active
96    pub status: crate::config::SourceStatus,
97    /// Effective classification tags (#376) = source-template `tags` ∪ row
98    /// `tags` (union, deduped, sorted). Drives the runtime `--tag` narrowing;
99    /// does not affect the state key.
100    pub tags: Vec<String>,
101    /// Every `${id.path}` placeholder that survived load-time interpolation.
102    /// Populated by `collect_deferred`; the executor uses this to know
103    /// which parent record to feed which row.
104    pub deferred_refs: Vec<DeferredRef>,
105    /// A pre-built source that replaces the registry-built one for this node.
106    /// Set only by `faucet dlq replay` (#281), which injects a
107    /// [`DlqReaderSource`](crate::dlq_replay::reader::DlqReaderSource) so the
108    /// executor runs it through the normal pipeline path. `None` for every
109    /// config-driven node (the executor builds the source from `source.kind`).
110    pub source_override: Option<crate::dlq_replay::reader::SourceOverride>,
111    /// Scoped-cleanup claim (#478): the source's `complete_for` scope, still
112    /// carrying any `${parent.*}` / `${now.*}` tokens — the executor resolves
113    /// them per invocation, like the connector configs. `Some` only when the
114    /// destination sink also opted in with `cleanup: delete_missing`, so this
115    /// being present already means a cleanup is intended.
116    pub cleanup_scope: Option<std::collections::BTreeMap<String, serde_json::Value>>,
117    /// Pipeline-level `_faucet_*` metadata columns (#510), shared by every node;
118    /// the executor wraps the sink in a `MetadataSink` decorator when present.
119    pub metadata_columns: Option<faucet_core::MetadataColumnsSpec>,
120}
121
122#[derive(Debug, Clone)]
123pub enum NodeRole {
124    /// Root node — runs once per pipeline invocation.
125    Root,
126    /// Child node — runs once per record produced by the parent row.
127    Child {
128        parent_id: String,
129        parent_key: String,
130    },
131    /// Discovery dimension (#501) — runs its source once, projects `select`,
132    /// dedups, and publishes the value-set under `as_alias`. No sink.
133    ///
134    /// **Chained / collected discovery (#531):** when `dims` is non-empty the
135    /// discovery is itself fanned out over the cartesian product of those
136    /// upstream discovery dimensions (its `for_each`), running once per tuple
137    /// with `${dim}` tokens resolved in its source config; with `collect: true`
138    /// each tuple's value-set is published as one list keyed by the tuple
139    /// (a [`CollectedDim`](crate::discovery_matrix::CollectedDim)) rather than as
140    /// a flat cartesian axis.
141    Discovery {
142        select: String,
143        as_alias: String,
144        collect: bool,
145        dims: Vec<String>,
146    },
147    /// Discovery-driven fan-out (#501) — runs once per tuple of the cartesian
148    /// product of the named discovery dimensions. `dims` are discovery row ids
149    /// (also mirrored into `depends_on` for readiness/skip/cycle reuse).
150    /// `collected` (#531) names the collected discovery rows this row references
151    /// (`${id.alias}`), whose per-tuple lists are injected into each tuple ctx.
152    Product {
153        dims: Vec<String>,
154        collected: Vec<String>,
155    },
156}
157
158#[derive(Debug, Clone)]
159pub struct DeferredRef {
160    pub referenced_id: String,
161    pub dotted_path: String,
162    pub token: String,
163}
164
165/// In-memory lookup of source / sink templates, built once per `expand()` call.
166/// Combines named entries from `pipeline.sources` / `pipeline.sinks` with the
167/// legacy singular `pipeline.source` / `pipeline.sink` (registered as `default`).
168struct Registry<'a> {
169    sources: HashMap<&'a str, &'a ConnectorSpec>,
170    sinks: HashMap<&'a str, &'a ConnectorSpec>,
171}
172
173impl<'a> Registry<'a> {
174    fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
175        let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
176        if let Some(default) = spec.source.as_ref() {
177            sources.insert("default", default);
178        }
179        for (name, s) in spec.sources.iter() {
180            if sources.contains_key(name.as_str()) {
181                return Err(CliError::DuplicateTemplate {
182                    kind: "source",
183                    name: name.clone(),
184                });
185            }
186            sources.insert(name.as_str(), s);
187        }
188
189        let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
190        if let Some(default) = spec.sink.as_ref() {
191            if default.transforms.is_some() {
192                return Err(CliError::TransformsOnSink {
193                    name: "default".to_string(),
194                });
195            }
196            if !default.inherit_transforms {
197                return Err(CliError::InheritTransformsOnSink {
198                    name: "default".to_string(),
199                });
200            }
201            sinks.insert("default", default);
202        }
203        for (name, s) in spec.sinks.iter() {
204            if sinks.contains_key(name.as_str()) {
205                return Err(CliError::DuplicateTemplate {
206                    kind: "sink",
207                    name: name.clone(),
208                });
209            }
210            if s.transforms.is_some() {
211                return Err(CliError::TransformsOnSink { name: name.clone() });
212            }
213            if !s.inherit_transforms {
214                return Err(CliError::InheritTransformsOnSink { name: name.clone() });
215            }
216            sinks.insert(name.as_str(), s);
217        }
218        Ok(Self { sources, sinks })
219    }
220
221    fn known(&self, kind: &'static str) -> Vec<String> {
222        debug_assert!(
223            matches!(kind, "source" | "sink"),
224            "Registry::known called with kind = {:?}",
225            kind
226        );
227        let map = if kind == "source" {
228            &self.sources
229        } else {
230            &self.sinks
231        };
232        let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
233        out.sort();
234        out
235    }
236
237    fn resolve(
238        &self,
239        kind: &'static str,
240        row_id: &str,
241        overlay: Option<&PartialConnector>,
242    ) -> CliResult<ConnectorSpec> {
243        debug_assert!(
244            matches!(kind, "source" | "sink"),
245            "Registry::resolve called with kind = {:?}",
246            kind
247        );
248        let map = if kind == "source" {
249            &self.sources
250        } else {
251            &self.sinks
252        };
253        let ref_name = overlay
254            .and_then(|p| p.r#ref.as_deref())
255            .unwrap_or("default");
256        let base = map.get(ref_name).ok_or_else(|| {
257            if ref_name == "default" {
258                CliError::MissingTemplate {
259                    kind,
260                    row_id: row_id.to_owned(),
261                }
262            } else {
263                CliError::UnknownTemplate {
264                    kind,
265                    name: ref_name.to_owned(),
266                    row_id: row_id.to_owned(),
267                    known: self.known(kind),
268                }
269            }
270        })?;
271        let mut out = (*base).clone();
272        if let Some(p) = overlay {
273            if let Some(k) = &p.kind {
274                out.kind = k.clone();
275            }
276            if let Some(c) = &p.config {
277                merge_value(&mut out.config, c.clone());
278            }
279            // Readiness ladder is a scalar: a row `source.status` replaces the
280            // template's (#371). `tags` are handled separately (union, not
281            // replace) in `expand`, since `PartialConnector` carries no `tags`.
282            if p.status.is_some() {
283                out.status = p.status;
284            }
285        }
286        Ok(out)
287    }
288}
289
290/// Expand `cfg` into a topologically valid list of nodes. Roots come first,
291/// then children in BFS order.
292pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
293    // Fail-fast at config load: validate the execution-level adaptive
294    // batch-size controller here (the shared `validate`/`run`/`preview`/
295    // `doctor`/`schedule` gate) so `faucet validate` rejects a bad block
296    // rather than only surfacing it mid-run in the executor.
297    if let Some(ab) = cfg
298        .execution
299        .as_ref()
300        .and_then(|e| e.adaptive_batch_size.as_ref())
301    {
302        // `validate()` returns `FaucetError::Config` whose message already names
303        // the offending field; propagate it directly (CliError: From<FaucetError>).
304        ab.validate()?;
305    }
306
307    // Implicit single-row case: empty matrix → run pipeline once with no merge.
308    let synthetic_row;
309    let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
310        synthetic_row = [MatrixRow {
311            id: None,
312            parent: None,
313            depends_on: Vec::new(),
314            parent_key: "id".into(),
315            source: None,
316            sink: None,
317            transforms: None,
318            inherit_transforms: true,
319            state: None,
320            dlq: None,
321            delivery: None,
322            tags: Vec::new(),
323            partition: None,
324            discover: None,
325            for_each: Vec::new(),
326        }];
327        &synthetic_row
328    } else {
329        &cfg.matrix
330    };
331
332    // 1) Assign / validate ids.
333    let mut ids: Vec<String> = Vec::with_capacity(rows.len());
334    let mut seen: HashSet<String> = HashSet::new();
335    for (i, row) in rows.iter().enumerate() {
336        let id = match &row.id {
337            Some(s) => s.clone(),
338            None => format!("row-{i}"),
339        };
340        if RESERVED_IDS.contains(&id.as_str()) {
341            return Err(CliError::ReservedRowId { id });
342        }
343        if !seen.insert(id.clone()) {
344            return Err(CliError::DuplicateRowId { id });
345        }
346        ids.push(id);
347    }
348    // Flow-auth capture names (#567) are runtime-resolved deferred tokens: a
349    // source body/header may reference `${session_id}` where `session_id` is
350    // captured by a `type: flow` provider's login step and substituted per
351    // request by the connector. Treat them like row ids for `${...}` validation.
352    let capture_names: Vec<String> = collect_flow_capture_names(cfg);
353    let id_set: HashSet<&str> = ids
354        .iter()
355        .chain(capture_names.iter())
356        .map(String::as_str)
357        .collect();
358
359    // 1b) Discovery-driven matrix (#501): identify `discover:` rows and validate
360    // the `discover:` / `for_each:` shapes before the graph checks below, so
361    // `for_each` dims can be folded into the dependency graph.
362    let discovery_ids: HashSet<&str> = rows
363        .iter()
364        .zip(ids.iter())
365        .filter(|(row, _)| row.discover.is_some())
366        .map(|(_, id)| id.as_str())
367        .collect();
368    // Collected (list-valued) discovery rows (#531) — referenced via `${id.alias}`
369    // (not via `for_each`), so a consuming row depends on them explicitly.
370    let collect_discovery_ids: HashSet<&str> = rows
371        .iter()
372        .zip(ids.iter())
373        .filter(|(row, _)| row.discover.as_ref().is_some_and(|d| d.collect))
374        .map(|(_, id)| id.as_str())
375        .collect();
376    for (i, row) in rows.iter().enumerate() {
377        let id = ids[i].as_str();
378        if let Some(disc) = &row.discover {
379            // Chained / two-level discovery (#531): a `discover:` row MAY also
380            // declare `for_each:` — it then runs once per upstream tuple and must
381            // `collect: true` (publish one list per tuple, not a cartesian axis).
382            if !row.for_each.is_empty() && !disc.collect {
383                return Err(CliError::Config(format!(
384                    "matrix row '{id}': a chained `discover:` row (with `for_each:`) must set \
385                     `collect: true` — it publishes one list per upstream tuple"
386                )));
387            }
388            if disc.collect && row.for_each.is_empty() {
389                return Err(CliError::Config(format!(
390                    "matrix row '{id}': `discover.collect: true` requires `for_each:` — it collects \
391                     one list per upstream discovery tuple"
392                )));
393            }
394            if row.parent.is_some() {
395                return Err(CliError::Config(format!(
396                    "matrix row '{id}': a `discover:` row cannot also declare `parent:`"
397                )));
398            }
399            if row.sink.is_some() {
400                return Err(CliError::Config(format!(
401                    "matrix row '{id}': a `discover:` row has no sink — remove its `sink:` override"
402                )));
403            }
404            if row.transforms.is_some() {
405                return Err(CliError::Config(format!(
406                    "matrix row '{id}': a `discover:` row does not run transforms"
407                )));
408            }
409            if disc.select.trim().is_empty() {
410                return Err(CliError::Config(format!(
411                    "matrix row '{id}': `discover.select` must not be empty"
412                )));
413            }
414            if !is_ident(&disc.as_alias) {
415                return Err(CliError::Config(format!(
416                    "matrix row '{id}': `discover.as` ('{}') must match ^[a-z0-9][a-z0-9_-]*$",
417                    disc.as_alias
418                )));
419            }
420        }
421        if !row.for_each.is_empty() {
422            if row.parent.is_some() {
423                return Err(CliError::Config(format!(
424                    "matrix row '{id}': `for_each:` and `parent:` cannot be combined (v1) — a row \
425                     fans out over the discovery cross-product OR a parent's records, not both"
426                )));
427            }
428            let mut seen_dims: HashSet<&str> = HashSet::new();
429            for dim in &row.for_each {
430                if dim.as_str() == id {
431                    return Err(CliError::Config(format!(
432                        "matrix row '{id}': `for_each` cannot reference itself"
433                    )));
434                }
435                if !id_set.contains(dim.as_str()) {
436                    return Err(CliError::Config(format!(
437                        "matrix row '{id}': `for_each` references unknown row '{dim}'"
438                    )));
439                }
440                if !discovery_ids.contains(dim.as_str()) {
441                    return Err(CliError::Config(format!(
442                        "matrix row '{id}': `for_each` row '{dim}' is not a `discover:` row"
443                    )));
444                }
445                if !seen_dims.insert(dim.as_str()) {
446                    return Err(CliError::Config(format!(
447                        "matrix row '{id}': `for_each` lists '{dim}' more than once"
448                    )));
449                }
450            }
451        }
452    }
453
454    // 2) Validate parents + detect cycles.
455    let mut parents: HashMap<&str, &str> = HashMap::new();
456    for (i, row) in rows.iter().enumerate() {
457        let id = ids[i].as_str();
458        if let Some(parent) = row.parent.as_deref() {
459            if !id_set.contains(parent) {
460                return Err(CliError::UnknownParent {
461                    id: id.to_owned(),
462                    parent: parent.to_owned(),
463                });
464            }
465            if parent == id {
466                return Err(CliError::ParentCycle {
467                    ids: vec![id.to_owned()],
468                });
469            }
470            parents.insert(id, parent);
471        }
472    }
473    detect_cycle(&parents)?;
474
475    // 2b) Validate `depends_on` edges (unknown id, self-dependency) and
476    // dedup each row's list while preserving declaration order. Then check
477    // the *combined* parent + depends_on graph for cycles — `detect_cycle`
478    // above only walks single-parent chains, so a cycle routed through a
479    // `depends_on` edge would otherwise deadlock the executor at run time.
480    let mut deps_by_row: Vec<Vec<String>> = Vec::with_capacity(rows.len());
481    // Collected discovery rows (#531) each row references via `${id.alias}`, in
482    // referenced order (deduped). Consumed when building the `Product` role so
483    // each tuple ctx gets the collected list injected.
484    let mut collected_refs_by_row: Vec<Vec<String>> = Vec::with_capacity(rows.len());
485    for (i, row) in rows.iter().enumerate() {
486        let id = ids[i].as_str();
487        let mut deps: Vec<String> = Vec::with_capacity(row.depends_on.len());
488        for dep in &row.depends_on {
489            if !id_set.contains(dep.as_str()) {
490                return Err(CliError::UnknownDependency {
491                    id: id.to_owned(),
492                    depends_on: dep.clone(),
493                });
494            }
495            if dep == id {
496                return Err(CliError::DependencyCycle {
497                    ids: vec![id.to_owned()],
498                });
499            }
500            if !deps.contains(dep) {
501                deps.push(dep.clone());
502            }
503        }
504        // A `for_each` row (#501) waits for every discovery dimension it fans
505        // out over; model those as `depends_on` edges so readiness, the skip
506        // cascade, and cycle detection all reuse the existing machinery.
507        for dim in &row.for_each {
508            if !deps.contains(dim) {
509                deps.push(dim.clone());
510            }
511        }
512        // Collected-discovery references (#531): `${props.name}` in a row's
513        // config makes it depend on `props` (which is NOT one of its `for_each`
514        // dims), so `props` runs first and its per-tuple lists are available.
515        let mut collected_refs: Vec<String> = Vec::new();
516        let mut refs = Vec::new();
517        if let Some(p) = &row.source
518            && let Some(c) = &p.config
519        {
520            collect_deferred(c, &mut refs);
521        }
522        if let Some(p) = &row.sink
523            && let Some(c) = &p.config
524        {
525            collect_deferred(c, &mut refs);
526        }
527        if let Some(disc) = &row.discover
528            && let Some(c) = &disc.source.config
529        {
530            collect_deferred(c, &mut refs);
531        }
532        for r in &refs {
533            if r.referenced_id == id {
534                continue;
535            }
536            if collect_discovery_ids.contains(r.referenced_id.as_str()) {
537                if !deps.contains(&r.referenced_id) {
538                    deps.push(r.referenced_id.clone());
539                }
540                if !collected_refs.contains(&r.referenced_id) {
541                    collected_refs.push(r.referenced_id.clone());
542                }
543            }
544        }
545        deps_by_row.push(deps);
546        collected_refs_by_row.push(collected_refs);
547    }
548    detect_combined_cycle(&ids, &parents, &deps_by_row)?;
549
550    // 3) Validate `${id.path}` references — each `id` must be a known row.
551    // We scan the *raw* (pre-merge) row configs because interpolation lives in
552    // strings that survive merging unchanged.
553    for (i, row) in rows.iter().enumerate() {
554        let id = ids[i].as_str();
555        if let Some(p) = &row.source
556            && let Some(c) = &p.config
557        {
558            check_refs(c, &id_set, id)?;
559        }
560        if let Some(p) = &row.sink
561            && let Some(c) = &p.config
562        {
563            check_refs(c, &id_set, id)?;
564        }
565        // A chained `discover:` row's source config (#531) references its upstream
566        // dimension (`${types.name}`); validate those refs too.
567        if let Some(disc) = &row.discover
568            && let Some(c) = &disc.source.config
569        {
570            check_refs(c, &id_set, id)?;
571        }
572    }
573    if let Some(s) = &cfg.pipeline.source {
574        check_refs(&s.config, &id_set, "pipeline.source")?;
575    }
576    if let Some(s) = &cfg.pipeline.sink {
577        check_refs(&s.config, &id_set, "pipeline.sink")?;
578    }
579    for (name, s) in &cfg.pipeline.sources {
580        check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
581    }
582    for (name, s) in &cfg.pipeline.sinks {
583        check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
584    }
585
586    // 4) Build template registry — validates duplicate default conflicts.
587    let registry = Registry::build(&cfg.pipeline)?;
588
589    // 5) Build expanded nodes. Order: roots first (in declaration order),
590    // then BFS over children — guarantees a parent appears before its children.
591    let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
592    let mut roots: Vec<usize> = Vec::new();
593    for (i, row) in rows.iter().enumerate() {
594        match row.parent.as_deref() {
595            None => roots.push(i),
596            Some(p) => by_parent.entry(p).or_default().push(i),
597        }
598    }
599
600    let mut order: Vec<usize> = Vec::with_capacity(rows.len());
601    let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
602    while let Some(idx) = queue.pop_front() {
603        order.push(idx);
604        if let Some(children) = by_parent.get(ids[idx].as_str()) {
605            queue.extend(children.iter().copied());
606        }
607    }
608    debug_assert_eq!(order.len(), rows.len());
609
610    let mut out = Vec::with_capacity(rows.len());
611    for &i in &order {
612        let row = &rows[i];
613        let row_id = ids[i].as_str();
614
615        // Discovery row (#501): a value-enumeration step with no sink. Build a
616        // minimal node from `discover.source` and skip the entire source→sink
617        // pipeline (templates, write-mode, exactly-once, drift, cleanup) — none
618        // of it applies to an enumeration that writes nothing.
619        if let Some(disc) = &row.discover {
620            // Resolve the discovery source: a `{ ref }` merges over the named
621            // `pipeline.sources` template; a standalone `{ type, config }` is
622            // used verbatim (NOT merged over `default`, which would pollute the
623            // enumeration with the data source's `${dim}` tokens).
624            let src = if disc.source.r#ref.is_some() {
625                registry.resolve("source", row_id, Some(&disc.source))?
626            } else {
627                let kind = disc.source.kind.clone().ok_or_else(|| {
628                    CliError::Config(format!(
629                        "matrix row '{row_id}': `discover.source` needs a `type` (or a `ref` to a \
630                         pipeline.sources template)"
631                    ))
632                })?;
633                ConnectorSpec {
634                    kind,
635                    config: disc
636                        .source
637                        .config
638                        .clone()
639                        .unwrap_or_else(|| Value::Object(Default::default())),
640                    transforms: None,
641                    inherit_transforms: true,
642                    status: None,
643                    tags: Vec::new(),
644                    complete_for: None,
645                }
646            };
647            out.push(ExpandedNode {
648                id: ids[i].clone(),
649                row_index: i,
650                role: NodeRole::Discovery {
651                    select: disc.select.clone(),
652                    as_alias: disc.as_alias.clone(),
653                    collect: disc.collect,
654                    dims: row.for_each.clone(),
655                },
656                // `sink` is a never-built placeholder (run_discovery ignores it).
657                sink: src.clone(),
658                source: src,
659                transforms: Vec::new(),
660                state: None,
661                dlq: None,
662                delivery: faucet_core::DeliveryMode::AtLeastOnce,
663                delivery_guarantee: faucet_core::DeliveryGuarantee::AtLeastOnce,
664                #[cfg(feature = "quality")]
665                quality: None,
666                #[cfg(feature = "contract")]
667                contract: None,
668                #[cfg(feature = "masking")]
669                masking: None,
670                sink_ref: "default".to_string(),
671                schema: None,
672                depends_on: deps_by_row[i].clone(),
673                status: crate::config::SourceStatus::default(),
674                tags: Vec::new(),
675                deferred_refs: Vec::new(),
676                source_override: None,
677                cleanup_scope: None,
678                metadata_columns: None,
679            });
680            continue;
681        }
682
683        let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
684        let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
685        // The sink template name this row resolved (or the legacy `default`),
686        // used to scope masking `applies_to` per destination.
687        let sink_ref = row
688            .sink
689            .as_ref()
690            .and_then(|s| s.r#ref.clone())
691            .unwrap_or_else(|| "default".to_string());
692        let role = if !row.for_each.is_empty() {
693            // Discovery-driven fan-out (#501): runs once per cartesian-product
694            // tuple of the named dimensions (also mirrored into `depends_on`).
695            // `collected` (#531) names the collected discovery rows this row
696            // injects a per-tuple list from.
697            NodeRole::Product {
698                dims: row.for_each.clone(),
699                collected: collected_refs_by_row[i].clone(),
700            }
701        } else {
702            match &row.parent {
703                None => NodeRole::Root,
704                Some(p) => NodeRole::Child {
705                    parent_id: p.clone(),
706                    parent_key: row.parent_key.clone(),
707                },
708            }
709        };
710        let mut deferred = Vec::new();
711        collect_deferred(&merged_source.config, &mut deferred);
712        collect_deferred(&merged_sink.config, &mut deferred);
713
714        // Resolved readiness status (#371): `merged_source.status` already
715        // carries the template→row `source.status` scalar merge; default to
716        // `active` when neither declared it.
717        let status = merged_source.status.unwrap_or_default();
718
719        // Effective tags (#376) = source-template `tags` ∪ row `tags`. This is
720        // the one deliberate exception to `merge.rs`'s array-replace rule:
721        // tags union rather than replace. Validate + dedup + sort so the set is
722        // canonical and order-insensitive.
723        let tags = resolve_tags(&merged_source.tags, &row.tags, row_id)?;
724
725        // Resolve transforms, state, and DLQ (row overrides win over base).
726        // Three-layer additive resolution:
727        //   T_pipeline ++ T_source ++ T_row
728        // gated on each layer's `inherit_transforms` flag.
729        let src_inherit = merged_source.inherit_transforms;
730        let row_inherit = row.inherit_transforms;
731        let mut transforms: Vec<TransformSpec> = Vec::new();
732        if src_inherit && row_inherit {
733            transforms.extend(cfg.pipeline.transforms.iter().cloned());
734        }
735        if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
736            transforms.extend(src_ts.iter().cloned());
737        }
738        if let Some(row_ts) = row.transforms.as_ref() {
739            transforms.extend(row_ts.iter().cloned());
740        }
741        let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
742        // Row override wins; fall back to the top-level delivery mode.
743        let delivery = row.delivery.unwrap_or(cfg.delivery);
744        // Three-state match: Some(None) = disable, Some(Some(spec)) = replace,
745        // None = inherit. The naive `.flatten().or_else()` would conflate
746        // disable and absent, silently inheriting on explicit null.
747        let dlq = match row.dlq.clone() {
748            Some(None) => None,
749            Some(Some(spec)) => Some(spec),
750            None => cfg.pipeline.dlq.clone(),
751        };
752
753        if let Some(ref d) = dlq {
754            if matches!(d.max_failures_per_page, Some(0)) {
755                return Err(CliError::InvalidDlqBudget {
756                    field: "max_failures_per_page",
757                });
758            }
759            if matches!(d.max_failures_total, Some(0)) {
760                return Err(CliError::InvalidDlqBudget {
761                    field: "max_failures_total",
762                });
763            }
764            if !crate::registry::sink_exists(&d.sink.kind) {
765                return Err(CliError::UnknownDlqSinkKind {
766                    kind: d.sink.kind.clone(),
767                    context: format!("row `{row_id}`"),
768                });
769            }
770        }
771
772        // A transform's config may reference `${now.*}` and `${<parent-row>.*}`
773        // — the executor resolves both per invocation, exactly like source/sink
774        // configs (#568) — so validate them like source/sink (`check_refs`:
775        // `${now.*}` and known row ids pass, an unknown id fails) rather than
776        // blanket-rejecting every runtime token. State / dlq configs have no
777        // such runtime resolution, so they keep the stricter rejection below.
778        for (ti, t) in transforms.iter().enumerate() {
779            check_refs(
780                &t.config,
781                &id_set,
782                &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
783            )?;
784        }
785        if let Some(ref st) = state {
786            reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
787        }
788        if let Some(ref d) = dlq {
789            reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
790        }
791
792        // `quality:` is pipeline-level only in v1 (no matrix-row override), so
793        // every node carries the same spec. Compile it once per node to (a)
794        // surface invalid paths/regexes/bounds at expand time, and (b) fail
795        // fast when a quarantine check has no DLQ to route to — the core guards
796        // this at run start too, but catching it here makes `faucet validate`
797        // a friendly, fast failure.
798        #[cfg(feature = "quality")]
799        let quality = cfg.pipeline.quality.clone();
800        #[cfg(feature = "quality")]
801        if let Some(ref spec) = quality {
802            let compiled = faucet_core::CompiledQuality::compile(spec)
803                .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
804            if compiled.requires_dlq() && dlq.is_none() {
805                return Err(CliError::Config(format!(
806                    "row `{row_id}`: a quality check uses `on_failure: quarantine` \
807                     but no DLQ is configured — add a `dlq:` block (or change the \
808                     check's `on_failure` to `abort`)"
809                )));
810            }
811        }
812
813        // `contract:` is pipeline-level only in v1 (like `quality:`). Compile
814        // it once per node so a malformed contract (bad regex, duplicate
815        // fields, misplaced constraints) surfaces at expand time, and fail
816        // fast when `on_breach: quarantine` has no DLQ to route to.
817        #[cfg(feature = "contract")]
818        let contract = cfg.pipeline.contract.clone();
819        #[cfg(feature = "contract")]
820        if let Some(ref spec) = contract {
821            let compiled = faucet_core::CompiledContract::compile(spec)
822                .map_err(|e| CliError::Config(format!("contract (row `{row_id}`): {e}")))?;
823            if compiled.requires_dlq() && dlq.is_none() {
824                return Err(CliError::Config(format!(
825                    "row `{row_id}`: the contract uses `on_breach: quarantine` \
826                     but no DLQ is configured — add a `dlq:` block (or change \
827                     `on_breach` to `fail` or `warn`)"
828                )));
829            }
830        }
831
832        // `masking:` is pipeline-level only in v1 (like `quality:`/`contract:`).
833        // Compile it once per node so a malformed policy (empty rules, empty
834        // match, bad regex) surfaces at expand time. No DLQ gate — masking
835        // never quarantines; it rewrites matching fields in place.
836        #[cfg(feature = "masking")]
837        let masking = cfg.pipeline.masking.clone();
838        #[cfg(feature = "masking")]
839        if let Some(ref spec) = masking {
840            faucet_core::CompiledMasking::compile(spec)
841                .map_err(|e| CliError::Config(format!("masking (row `{row_id}`): {e}")))?;
842        }
843
844        // Resilience poison-pill cross-check: `poison.action: dlq` routes
845        // persistently-failing rows to the DLQ, so a DLQ must be configured.
846        // Caught here so `faucet validate` reports it before any run starts.
847        if let Some(spec) = &cfg.resilience
848            && matches!(
849                spec.poison.as_ref().map(|p| p.action),
850                Some(crate::config::PoisonActionSpec::Dlq)
851            )
852            && dlq.is_none()
853        {
854            return Err(CliError::Config(format!(
855                "row '{row_id}': resilience.poison.action=dlq requires a dlq: block"
856            )));
857        }
858
859        // SLA gate (load-time, #202): validate the spec once per row and
860        // require a `state:` block when staleness / volume-anomaly checks need
861        // persisted history. `min_rows_per_run` alone is stateless and passes
862        // without one.
863        if let Some(ref sla) = cfg.sla {
864            sla.validate()
865                .map_err(|e| CliError::Config(format!("sla: {e}")))?;
866            if sla.needs_state() {
867                match state.as_ref() {
868                    None => {
869                        return Err(CliError::Config(format!(
870                            "row '{row_id}': sla.max_staleness_secs / sla.volume_anomaly \
871                             need persisted run history — add a `state:` block \
872                             (min_rows_per_run alone works without one)"
873                        )));
874                    }
875                    Some(s) if s.kind == "memory" => {
876                        tracing::warn!(
877                            row = %row_id,
878                            "sla: the `memory` state store resets on process exit — \
879                             staleness/volume baselines only persist within a single \
880                             `faucet schedule`/`serve` process; use `file`, `redis`, \
881                             or `postgres` for one-shot runs"
882                        );
883                    }
884                    Some(_) => {}
885                }
886            }
887        }
888
889        // write_mode × sink validation (load-time): reject an unsupported mode
890        // for the sink kind, and upsert/delete without a key, before any run.
891        // Runs for every row; append rows pass trivially.
892        let requested_mode = merged_sink
893            .config
894            .get("write_mode")
895            .and_then(|v| v.as_str())
896            .unwrap_or("append");
897        let mode = match requested_mode {
898            "append" => faucet_core::WriteMode::Append,
899            "upsert" => faucet_core::WriteMode::Upsert,
900            "delete" => faucet_core::WriteMode::Delete,
901            "overwrite" => faucet_core::WriteMode::Overwrite,
902            other => {
903                return Err(CliError::Config(format!(
904                    "row '{}': unknown write_mode '{}' (expected append, upsert, delete, or overwrite)",
905                    ids[i], other
906                )));
907            }
908        };
909        if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
910            let sinks = if matches!(mode, faucet_core::WriteMode::Overwrite) {
911                format!(
912                    "overwrite sinks: {}",
913                    crate::registry::OVERWRITE_SINK_KINDS.join(", ")
914                )
915            } else {
916                format!(
917                    "upsert/delete sinks: {}",
918                    crate::registry::UPSERT_SINK_KINDS.join(", ")
919                )
920            };
921            return Err(CliError::Config(format!(
922                "row '{}': write_mode '{}' is not supported by sink '{}' ({})",
923                ids[i], requested_mode, merged_sink.kind, sinks
924            )));
925        }
926        if matches!(
927            mode,
928            faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
929        ) {
930            let key_present = merged_sink
931                .config
932                .get("key")
933                .and_then(|v| v.as_array())
934                .map(|a| !a.is_empty())
935                .unwrap_or(false);
936            if !key_present {
937                return Err(CliError::Config(format!(
938                    "row '{}': write_mode '{}' requires a non-empty `key`",
939                    ids[i], requested_mode
940                )));
941            }
942        }
943
944        // ── Overwrite gates (load-time, #492) ───────────────────────────
945        // Overwrite replaces the whole destination via an atomic begin/commit
946        // staging swap. It has no per-page watermark, and its staging table is
947        // a pre-run clone of the target — so it cannot compose with
948        // exactly-once delivery or with an in-place schema evolution that
949        // mutates the target mid-run. (Scoped cleanup is already rejected: it
950        // requires `write_mode: upsert`.) Checked before the delivery-guarantee
951        // derivation so an EO source + idempotent sink cannot slip overwrite
952        // onto the atomic-watermark path.
953        if matches!(mode, faucet_core::WriteMode::Overwrite) {
954            if delivery == faucet_core::DeliveryMode::ExactlyOnce {
955                return Err(CliError::Config(format!(
956                    "row '{}': write_mode: overwrite is incompatible with delivery: exactly_once \
957                     — a full-destination replace has no per-page watermark to resume from",
958                    ids[i]
959                )));
960            }
961            if let Some(ref sd) = cfg.pipeline.schema
962                && faucet_core::SchemaDriftPolicy::compile(sd).on_drift
963                    == faucet_core::OnDrift::Evolve
964            {
965                return Err(CliError::Config(format!(
966                    "row '{}': write_mode: overwrite is incompatible with schema.on_drift: evolve \
967                     — overwrite stages into a pre-run clone of the target, so evolving the \
968                     target mid-run would leave the staged data a column short at swap time",
969                    ids[i]
970                )));
971            }
972        }
973
974        // ── Scoped/windowed overwrite gate (#518) ───────────────────────────
975        // A `scope:` block replaces only the rows matching it (a date window)
976        // instead of truncating. Valid only with `write_mode: overwrite` on a
977        // sink that implements the scoped begin/delete/insert swap.
978        if let Some(scope_val) = merged_sink.config.get("scope") {
979            if !matches!(mode, faucet_core::WriteMode::Overwrite) {
980                return Err(CliError::Config(format!(
981                    "row '{}': `scope` is only valid with `write_mode: overwrite`",
982                    ids[i]
983                )));
984            }
985            if !crate::registry::sink_supports_scoped_overwrite(&merged_sink.kind) {
986                return Err(CliError::Config(format!(
987                    "row '{}': scoped overwrite (`scope`) is not supported by sink '{}' \
988                     (scoped-overwrite sinks: {})",
989                    ids[i],
990                    merged_sink.kind,
991                    crate::registry::SCOPED_OVERWRITE_SINK_KINDS.join(", ")
992                )));
993            }
994            let scope: faucet_core::OverwriteScope = serde_json::from_value(scope_val.clone())
995                .map_err(|e| CliError::Config(format!("row '{}': invalid `scope`: {e}", ids[i])))?;
996            scope
997                .validate()
998                .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
999        }
1000
1001        // Derived end-to-end delivery guarantee (issue #292): computed for
1002        // *every* row — regardless of the requested `delivery:` mode — so
1003        // `faucet validate` / `doctor` report the truth (a keyed-upsert row is
1004        // effectively-once even when the user did not request `exactly_once`).
1005        // `keyed_upsert_configured` relies on the write_mode gate above: after
1006        // it, an upsert/delete mode implies a non-empty `key`.
1007        let keyed_upsert_configured = matches!(
1008            mode,
1009            faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
1010        );
1011        let guarantee_inputs = faucet_core::GuaranteeInputs {
1012            replay: crate::registry::source_replay_guarantee(&merged_source.kind),
1013            sink_atomic: crate::registry::sink_supports_idempotent_writes(&merged_sink.kind),
1014            keyed_upsert_configured,
1015            durable_state: matches!(state.as_ref(), Some(s) if s.kind != "memory"),
1016            dlq: dlq.is_some(),
1017        };
1018        let delivery_guarantee = faucet_core::derive_delivery_guarantee(&guarantee_inputs);
1019
1020        // Exactly-once delivery gate: `delivery: exactly_once` means "require
1021        // ≥ effectively-once". Enforced at config-load time so `faucet
1022        // validate` catches an unsupported topology before any run starts,
1023        // with the error naming the limiting side. A derived `AtLeastOnce`
1024        // implies keyed dedup is not configured (the keyed mechanism has no
1025        // other requirement), so the cascade below walks the atomic-watermark
1026        // requirements in order.
1027        if delivery == faucet_core::DeliveryMode::ExactlyOnce
1028            && delivery_guarantee == faucet_core::DeliveryGuarantee::AtLeastOnce
1029        {
1030            if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
1031                let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
1032                {
1033                    format!(
1034                        ", or configure `write_mode: upsert` + `key` on sink '{}' for \
1035                         keyed-upsert effectively-once with any source",
1036                        merged_sink.kind
1037                    )
1038                } else {
1039                    String::new()
1040                };
1041                return Err(CliError::Config(format!(
1042                    "row '{}': delivery: exactly_once is not supported by source '{}' \
1043                     (deterministic-replay sources only: {}{})",
1044                    ids[i],
1045                    merged_source.kind,
1046                    crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", "),
1047                    keyed_hint
1048                )));
1049            }
1050            if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
1051                let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
1052                {
1053                    format!(
1054                        "; alternatively configure `write_mode: upsert` + `key` on '{}' for \
1055                         keyed-upsert effectively-once",
1056                        merged_sink.kind
1057                    )
1058                } else {
1059                    String::new()
1060                };
1061                return Err(CliError::Config(format!(
1062                    "row '{}': delivery: exactly_once is not supported by sink '{}' \
1063                     (idempotent sinks only: {}{})",
1064                    ids[i],
1065                    merged_sink.kind,
1066                    crate::registry::IDEMPOTENT_SINK_KINDS.join(", "),
1067                    keyed_hint
1068                )));
1069            }
1070            // Require a *durable* state store. The atomic-watermark mechanism
1071            // persists the monotonic page sequence alongside the bookmark
1072            // (`wrap_state(bookmark, seq)`) and resumes from it across
1073            // restarts; the in-process `memory` store loses that watermark on
1074            // exit, so a restart would re-run already-committed pages — exactly
1075            // the duplication exactly-once exists to prevent (F24). Mirror the
1076            // `faucet replicate` gate, which already rejects `memory`.
1077            match state.as_ref() {
1078                None => {
1079                    return Err(CliError::Config(format!(
1080                        "row '{}': delivery: exactly_once requires a state store",
1081                        ids[i]
1082                    )));
1083                }
1084                Some(s) if s.kind == "memory" => {
1085                    return Err(CliError::Config(format!(
1086                        "row '{}': delivery: exactly_once requires a durable state store, \
1087                         not `memory` — the cross-restart watermark/sequence guarantee \
1088                         depends on it (use `file`, `redis`, or `postgres`)",
1089                        ids[i]
1090                    )));
1091                }
1092                Some(_) => {}
1093            }
1094            if dlq.is_some() {
1095                return Err(CliError::Config(format!(
1096                    "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
1097                    ids[i]
1098                )));
1099            }
1100            // The cascade above covers every way the derivation can land on
1101            // at-least-once; reaching here would mean it diverged from the
1102            // checks.
1103            unreachable!("delivery-guarantee derivation and the exactly-once gate diverged");
1104        }
1105
1106        // ── Scoped-cleanup gates (load-time, #478) ──────────────────────
1107        // Cleanup DELETES data, so every precondition is checked before a run
1108        // starts rather than discovered mid-flight.
1109        if merged_sink.complete_for.is_some() {
1110            return Err(CliError::Config(format!(
1111                "row '{}': `complete_for` belongs on the source, not the sink — only the \
1112                 source can claim a fetch returned every record for a scope",
1113                ids[i]
1114            )));
1115        }
1116        let cleanup_scope = match merged_source.complete_for.as_ref() {
1117            None => None,
1118            Some(claim) if claim.on_missing == crate::config::OnMissing::Ignore => {
1119                // A claim with no action is inert by design — it documents the
1120                // scope without authorising a delete.
1121                None
1122            }
1123            Some(claim) => {
1124                if claim.scope.is_empty() {
1125                    return Err(CliError::Config(format!(
1126                        "row '{}': `complete_for.scope` is empty — an empty scope matches \
1127                         every row in the destination",
1128                        ids[i]
1129                    )));
1130                }
1131                if !crate::registry::sink_supports_cleanup(&merged_sink.kind) {
1132                    return Err(CliError::Config(format!(
1133                        "row '{}': `complete_for.on_missing: delete` is not supported by sink \
1134                         '{}' (cleanup-capable sinks: {})",
1135                        ids[i],
1136                        merged_sink.kind,
1137                        crate::registry::CLEANUP_SINK_KINDS.join(", ")
1138                    )));
1139                }
1140                if !matches!(mode, faucet_core::WriteMode::Upsert) {
1141                    return Err(CliError::Config(format!(
1142                        "row '{}': `complete_for.on_missing: delete` requires \
1143                         `write_mode: upsert` (got '{}') — on an append sink there is no key \
1144                         to tell a written row from a stale one",
1145                        ids[i], requested_mode
1146                    )));
1147                }
1148                // Cleanup is a second, non-idempotent write outside the
1149                // commit-token transaction, so it cannot compose with the
1150                // atomic-watermark path.
1151                if matches!(delivery, faucet_core::DeliveryMode::ExactlyOnce) {
1152                    return Err(CliError::Config(format!(
1153                        "row '{}': `complete_for.on_missing: delete` is incompatible with \
1154                         `delivery: exactly_once` — the scoped delete happens outside the \
1155                         commit-token transaction, so it cannot be replayed idempotently",
1156                        ids[i]
1157                    )));
1158                }
1159                // A quarantined record never reaches the sink, so the cleanup
1160                // tracker never sees its key — and the delete would then remove
1161                // its destination row, losing data the source still has. Reject
1162                // rather than silently delete.
1163                let mut quarantines: Vec<&str> = Vec::new();
1164                #[cfg(feature = "quality")]
1165                if let Some(q) = cfg.pipeline.quality.as_ref()
1166                    && faucet_core::CompiledQuality::compile(q)
1167                        .map(|c| c.requires_dlq())
1168                        .unwrap_or(false)
1169                {
1170                    quarantines.push("quality");
1171                }
1172                #[cfg(feature = "contract")]
1173                if let Some(c) = cfg.pipeline.contract.as_ref()
1174                    && faucet_core::CompiledContract::compile(c)
1175                        .map(|c| c.requires_dlq())
1176                        .unwrap_or(false)
1177                {
1178                    quarantines.push("contract");
1179                }
1180                if let Some(sd) = cfg.pipeline.schema.as_ref()
1181                    && faucet_core::SchemaDriftPolicy::compile(sd).requires_dlq()
1182                {
1183                    quarantines.push("schema");
1184                }
1185                if !quarantines.is_empty() {
1186                    return Err(CliError::Config(format!(
1187                        "row '{}': `complete_for.on_missing: delete` is incompatible with a \
1188                         quarantining `{}` policy — a quarantined record never reaches the \
1189                         sink, so cleanup cannot tell it from a record deleted at the source \
1190                         and would delete its destination row",
1191                        ids[i],
1192                        quarantines.join("`/`")
1193                    )));
1194                }
1195                Some(claim.scope.clone())
1196            }
1197        };
1198
1199        // Schema-drift policy gates (load-time):
1200        //  - `evolve` requires an evolution-capable sink.
1201        //  - `quarantine` (drift or incompatible) requires a DLQ, and is
1202        //    incompatible with exactly-once (which forbids a DLQ).
1203        if let Some(ref sd) = cfg.pipeline.schema {
1204            let policy = faucet_core::SchemaDriftPolicy::compile(sd);
1205            if policy.on_drift == faucet_core::OnDrift::Evolve
1206                && !crate::registry::sink_supports_schema_evolution(&merged_sink.kind)
1207            {
1208                return Err(CliError::Config(format!(
1209                    "row '{}': schema.on_drift: evolve is not supported by sink '{}' \
1210                     (evolvable sinks: postgres, mysql, mssql, sqlite, bigquery, elasticsearch)",
1211                    ids[i], merged_sink.kind
1212                )));
1213            }
1214            if policy.requires_dlq() && dlq.is_none() {
1215                return Err(CliError::Config(format!(
1216                    "row '{}': schema.on_drift/on_incompatible 'quarantine' requires a `dlq:` block",
1217                    ids[i]
1218                )));
1219            }
1220            if policy.requires_dlq() && delivery == faucet_core::DeliveryMode::ExactlyOnce {
1221                return Err(CliError::Config(format!(
1222                    "row '{}': schema quarantine is incompatible with delivery: exactly_once \
1223                     (exactly_once forbids a DLQ)",
1224                    ids[i]
1225                )));
1226            }
1227        }
1228
1229        // ── Range partitioning (#479) ───────────────────────────────────
1230        // A partitioned row becomes N nodes, one per chunk, each with the
1231        // chunk's `${partition.*}` tokens already substituted into its connector
1232        // configs. Everything downstream — the executor, state keys, the
1233        // concurrency semaphore — then treats them as ordinary sibling rows,
1234        // which is why the fan-out costs no executor changes.
1235        let partition_spec = row.partition.clone().or_else(|| {
1236            // The top-level block is a default for *root* rows only. A child
1237            // fans out per parent record already; combining both would multiply
1238            // the two fan-outs, which is never what a top-level default meant.
1239            matches!(role, NodeRole::Root)
1240                .then(|| cfg.partition.clone())
1241                .flatten()
1242        });
1243        let chunks = match partition_spec.as_ref() {
1244            None => Vec::new(),
1245            Some(spec) => {
1246                // A partitioned row's id gains a chunk suffix, so any row that
1247                // names it as `parent:` or in `depends_on:` would resolve to a
1248                // node that no longer exists. Reject rather than silently
1249                // dropping the dependent's edge.
1250                let me = ids[i].as_str();
1251                let dependents: Vec<&str> = rows
1252                    .iter()
1253                    .enumerate()
1254                    .filter(|(j, r)| {
1255                        *j != i
1256                            && (r.parent.as_deref() == Some(me)
1257                                || r.depends_on.iter().any(|d| d == me))
1258                    })
1259                    .map(|(j, _)| ids[j].as_str())
1260                    .collect();
1261                if !dependents.is_empty() {
1262                    return Err(CliError::Config(format!(
1263                        "row '{}': a partitioned row cannot be referenced by another row \
1264                         (`parent:` or `depends_on:`) — it expands into one node per chunk, \
1265                         so there is no single node for '{}' to attach to. Partition the \
1266                         dependent row instead, or drop the reference",
1267                        me,
1268                        dependents.join("', '")
1269                    )));
1270                }
1271                let serialized = merged_source.config.to_string();
1272                if !crate::partition::references_partition(&serialized) {
1273                    return Err(CliError::Config(format!(
1274                        "row '{}': a `partition:` block is set but the source config references \
1275                         no `${{partition.*}}` token — every chunk would run the identical \
1276                         query. Scope the source to the chunk (e.g. \
1277                         `?id_from=${{partition.start}}&id_to=${{partition.end}}`). Available \
1278                         tokens for kind `{}`: {}",
1279                        ids[i],
1280                        spec.kind_str(),
1281                        spec.token_names().join(", ")
1282                    )));
1283                }
1284                crate::partition::plan(spec)
1285                    .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?
1286            }
1287        };
1288        if chunks.len() >= crate::chunking::WARN_UNITS {
1289            tracing::warn!(
1290                row = %ids[i],
1291                chunks = chunks.len(),
1292                "this row plans a very large number of partitions; each is a full pipeline \
1293                 invocation with its own connector clients"
1294            );
1295        }
1296
1297        let base = ExpandedNode {
1298            id: ids[i].clone(),
1299            row_index: i,
1300            role,
1301            source: merged_source,
1302            sink: merged_sink,
1303            transforms,
1304            state,
1305            dlq,
1306            delivery,
1307            delivery_guarantee,
1308            #[cfg(feature = "quality")]
1309            quality,
1310            #[cfg(feature = "contract")]
1311            contract,
1312            #[cfg(feature = "masking")]
1313            masking,
1314            sink_ref,
1315            schema: cfg.pipeline.schema.clone(),
1316            depends_on: deps_by_row[i].clone(),
1317            status,
1318            tags,
1319            deferred_refs: deferred,
1320            source_override: None,
1321            cleanup_scope,
1322            metadata_columns: cfg.metadata_columns.clone(),
1323        };
1324
1325        if chunks.is_empty() {
1326            out.push(base);
1327        } else {
1328            // One node per chunk. The id carries the chunk suffix so state keys
1329            // (`{name}::{row}::partition::{chunk}`) and log lines stay distinct,
1330            // and `row_index` is kept identical so partitions of one row sort
1331            // together ahead of the next row.
1332            for chunk in &chunks {
1333                let mut n = base.clone();
1334                n.id = format!("{}::partition::{}", base.id, chunk.id);
1335                crate::partition::substitute(&mut n.source.config, chunk)
1336                    .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
1337                crate::partition::substitute(&mut n.sink.config, chunk)
1338                    .map_err(|e| CliError::Config(format!("row '{}': {e}", ids[i])))?;
1339                out.push(n);
1340            }
1341        }
1342    }
1343    Ok(out)
1344}
1345
1346fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
1347    // Each node has at most one parent ⇒ cycle detection is "walk parents
1348    // until we hit `None` or revisit a node we've already seen this walk".
1349    for &start in parents.keys() {
1350        let mut visited: BTreeSet<&str> = BTreeSet::new();
1351        let mut cur = start;
1352        while let Some(&p) = parents.get(cur) {
1353            if !visited.insert(cur) {
1354                let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
1355                return Err(CliError::ParentCycle { ids: chain });
1356            }
1357            cur = p;
1358            if cur == start {
1359                let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
1360                chain.push(start.to_string());
1361                return Err(CliError::ParentCycle { ids: chain });
1362            }
1363        }
1364    }
1365    Ok(())
1366}
1367
1368/// Kahn's algorithm over the combined `parent:` + `depends_on:` edge set.
1369/// Pure-parent cycles are already caught by [`detect_cycle`] (with its more
1370/// specific error), so any leftover here necessarily involves a `depends_on`
1371/// edge. Rows that cannot be topologically ordered are the cycle participants
1372/// (plus any rows downstream of them — still actionable, since the report
1373/// names every row that would never become ready).
1374fn detect_combined_cycle(
1375    ids: &[String],
1376    parents: &HashMap<&str, &str>,
1377    deps_by_row: &[Vec<String>],
1378) -> CliResult<()> {
1379    let index_of: HashMap<&str, usize> = ids
1380        .iter()
1381        .enumerate()
1382        .map(|(i, id)| (id.as_str(), i))
1383        .collect();
1384    let mut in_degree = vec![0usize; ids.len()];
1385    let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
1386    for (i, id) in ids.iter().enumerate() {
1387        let mut prereqs: Vec<usize> = Vec::new();
1388        if let Some(p) = parents.get(id.as_str()) {
1389            prereqs.push(index_of[p]);
1390        }
1391        prereqs.extend(deps_by_row[i].iter().map(|d| index_of[d.as_str()]));
1392        for p in prereqs {
1393            in_degree[i] += 1;
1394            dependents[p].push(i);
1395        }
1396    }
1397    let mut queue: std::collections::VecDeque<usize> =
1398        (0..ids.len()).filter(|&i| in_degree[i] == 0).collect();
1399    let mut processed = 0usize;
1400    while let Some(i) = queue.pop_front() {
1401        processed += 1;
1402        for &d in &dependents[i] {
1403            in_degree[d] -= 1;
1404            if in_degree[d] == 0 {
1405                queue.push_back(d);
1406            }
1407        }
1408    }
1409    if processed < ids.len() {
1410        let mut stuck: Vec<String> = (0..ids.len())
1411            .filter(|&i| in_degree[i] > 0)
1412            .map(|i| ids[i].clone())
1413            .collect();
1414        stuck.sort();
1415        return Err(CliError::DependencyCycle { ids: stuck });
1416    }
1417    Ok(())
1418}
1419
1420/// Verify that every `${X.path}` token in `value` has `X` listed in `id_set`.
1421/// Load-time prefixes (`env`, `file`, `secret`) were already handled and are
1422/// ignored here.
1423/// Collect the capture names declared by every `type: flow` provider in the
1424/// top-level `auth:` catalog — the `capture` keys of each login step plus each
1425/// `apply[].name`. These become valid `${name}` deferred tokens so a source
1426/// body/header can reference a captured value the connector substitutes per
1427/// request (#567).
1428fn collect_flow_capture_names(cfg: &PipelineConfig) -> Vec<String> {
1429    let mut names = Vec::new();
1430    let Some(auth) = &cfg.auth else {
1431        return names;
1432    };
1433    for provider in auth.values() {
1434        if provider.get("type").and_then(Value::as_str) != Some("flow") {
1435            continue;
1436        }
1437        let Some(config) = provider.get("config") else {
1438            continue;
1439        };
1440        if let Some(steps) = config.get("steps").and_then(Value::as_array) {
1441            for step in steps {
1442                if let Some(cap) = step.get("capture").and_then(Value::as_object) {
1443                    names.extend(cap.keys().cloned());
1444                }
1445            }
1446        }
1447        if let Some(apply) = config.get("apply").and_then(Value::as_array) {
1448            for a in apply {
1449                if let Some(n) = a.get("name").and_then(Value::as_str) {
1450                    names.push(n.to_string());
1451                }
1452            }
1453        }
1454    }
1455    names
1456}
1457
1458fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
1459    walk_strings(value, &mut |s| {
1460        for (token, dir) in iter_directives(s) {
1461            // Load-time / template directives (`${env:..}`, `${vars.X}`, …) are
1462            // resolved before expansion; only deferred `${id.path}` references
1463            // are validated here, against the known row ids.
1464            // `now` and `backfill` are reserved built-in deferred ids
1465            // resolved at run time (`backfill` by `faucet backfill`, #282).
1466            // `${param.*}` is bound *pre-parse* (`params::bind_document`), so a
1467            // token surviving to expansion means the config was built through a
1468            // path that skipped binding — e.g. a host calling
1469            // `PipelineConfig::from_text`/`from_value` directly. Name the cause
1470            // rather than reporting a generic unknown row id (#444).
1471            if let Directive::Deferred { id, .. } = dir
1472                && id == crate::params::PARAM_ID
1473            {
1474                return Err(CliError::Config(format!(
1475                    "interpolation token `{token}` (in {owner}) was never bound — a `${{param.*}}` \
1476                     reference is resolved when the run is triggered. Load the config through \
1477                     `PipelineConfig::from_path*` (or supply values with `--param`) so params are \
1478                     bound before expansion"
1479                )));
1480            }
1481            if let Directive::Deferred { id, .. } = dir
1482                && id != "now"
1483                && id != "backfill"
1484                && id != "partition"
1485                && id != "bookmark"
1486                && id != "job_id"
1487                && id != "window"
1488                && !id_set.contains(id)
1489            {
1490                return Err(CliError::UnknownInterpolationId {
1491                    id: id.to_owned(),
1492                    token: format!("{token} (in {owner})"),
1493                });
1494            }
1495        }
1496        Ok(())
1497    })
1498}
1499
1500/// Reject any runtime interpolation token (`${id.path}` parent-record refs and
1501/// `${now.*}`) found in `value`. These resolve **only** in source/sink configs;
1502/// elsewhere — transform / state / dlq bodies — they would silently reach the
1503/// connector as a literal `${...}` string (#146 M2). Load-time directives
1504/// (`${env:}`, `${vars.X}`, `${sources.X}`, …) are already resolved before
1505/// expansion, so any deferred token still present here is genuinely
1506/// unsupported in this location.
1507fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
1508    walk_strings(value, &mut |s| {
1509        for (token, dir) in iter_directives(s) {
1510            if let Directive::Deferred { .. } = dir {
1511                return Err(CliError::Config(format!(
1512                    "interpolation token `{token}` in {location} is not supported: \
1513                     `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
1514                     resolve only in source/sink configs"
1515                )));
1516            }
1517        }
1518        Ok(())
1519    })
1520}
1521
1522/// Compute a row's effective tag set = `template_tags` ∪ `row_tags` (#376).
1523/// Union — the deliberate exception to `merge.rs`'s array-replace rule.
1524/// Validates each tag (charset `^[a-z0-9][a-z0-9_-]*$`, non-empty), dedups, and
1525/// returns a sorted, canonical list so `--tag` matching is order-insensitive.
1526fn resolve_tags(
1527    template_tags: &[String],
1528    row_tags: &[String],
1529    row_id: &str,
1530) -> CliResult<Vec<String>> {
1531    let mut set: BTreeSet<String> = BTreeSet::new();
1532    for tag in template_tags.iter().chain(row_tags.iter()) {
1533        validate_tag(tag, row_id)?;
1534        set.insert(tag.clone());
1535    }
1536    Ok(set.into_iter().collect())
1537}
1538
1539/// True when `s` matches `^[a-z0-9][a-z0-9_-]*$` (a discovery alias, #501).
1540fn is_ident(s: &str) -> bool {
1541    let mut chars = s.chars();
1542    match chars.next() {
1543        Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
1544            chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
1545        }
1546        _ => false,
1547    }
1548}
1549
1550/// A tag must be lowercase kebab/snake: `^[a-z0-9][a-z0-9_-]*$`.
1551fn validate_tag(tag: &str, row_id: &str) -> CliResult<()> {
1552    let ok = {
1553        let mut chars = tag.chars();
1554        match chars.next() {
1555            Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
1556                chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
1557            }
1558            _ => false,
1559        }
1560    };
1561    if !ok {
1562        return Err(CliError::Config(format!(
1563            "row '{row_id}': invalid tag '{tag}' — tags must match ^[a-z0-9][a-z0-9_-]*$ \
1564             (lowercase letters, digits, `_`, `-`; first char alphanumeric)"
1565        )));
1566    }
1567    Ok(())
1568}
1569
1570fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
1571    let _ = walk_strings(value, &mut |s| {
1572        for (token, dir) in iter_directives(s) {
1573            if let Directive::Deferred { id, path } = dir {
1574                // `now` / `backfill` / `partition` are reserved built-ins
1575                // resolved at run time, not parent-record dependencies — skip
1576                // them so the executor doesn't treat them as deferred
1577                // parent-record refs. `bookmark` is consumed *inside* a source's
1578                // `replication_bind.template` (#513), `window` inside a source's
1579                // `window.{lower,upper}.template` (#527) — the connector renders
1580                // them, so the CLI must pass them through untouched.
1581                if id == "now"
1582                    || id == "backfill"
1583                    || id == "partition"
1584                    || id == "bookmark"
1585                    || id == "job_id"
1586                    || id == "window"
1587                {
1588                    continue;
1589                }
1590                out.push(DeferredRef {
1591                    referenced_id: id.to_owned(),
1592                    dotted_path: path.to_owned(),
1593                    token: token.to_owned(),
1594                });
1595            }
1596        }
1597        Ok(())
1598    });
1599}
1600
1601fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
1602where
1603    F: FnMut(&str) -> CliResult<()>,
1604{
1605    match value {
1606        Value::String(s) => f(s),
1607        Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
1608        Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
1609        _ => Ok(()),
1610    }
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615    use super::*;
1616    use crate::config::{OnBatchErrorSpec, parse_with_extension};
1617
1618    fn cfg(yaml: &str) -> PipelineConfig {
1619        parse_with_extension(yaml, "yaml").unwrap()
1620    }
1621
1622    #[test]
1623    fn implicit_single_row_when_matrix_absent() {
1624        let c = cfg(r#"
1625version: 1
1626pipeline:
1627  source: { type: rest, config: { base_url: https://x } }
1628  sink:   { type: jsonl, config: { path: ./o } }
1629"#);
1630        let nodes = expand(&c).unwrap();
1631        assert_eq!(nodes.len(), 1);
1632        assert_eq!(nodes[0].id, "row-0");
1633        assert!(matches!(nodes[0].role, NodeRole::Root));
1634        assert_eq!(nodes[0].source.kind, "rest");
1635        assert_eq!(nodes[0].sink.kind, "jsonl");
1636    }
1637
1638    #[test]
1639    fn rejects_runtime_token_in_dlq_config() {
1640        // M2 (#146): `${now.*}` / `${parent.path}` resolve only in source/sink
1641        // configs. In a dlq config they would silently pass through as a literal
1642        // `${...}` string — expand must reject them with a clear error.
1643        let c = cfg(r#"
1644version: 1
1645pipeline:
1646  source: { type: rest, config: { base_url: https://x } }
1647  sink:   { type: jsonl, config: { path: ./o } }
1648  dlq:
1649    sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
1650"#);
1651        let err = expand(&c).unwrap_err();
1652        assert!(
1653            matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
1654            "got: {err:?}"
1655        );
1656    }
1657
1658    #[test]
1659    fn rejects_runtime_token_in_state_config() {
1660        let c = cfg(r#"
1661version: 1
1662pipeline:
1663  source: { type: rest, config: { base_url: https://x } }
1664  sink:   { type: jsonl, config: { path: ./o } }
1665  state:
1666    type: file
1667    config: { path: "state-${now.date}" }
1668"#);
1669        let err = expand(&c).unwrap_err();
1670        assert!(
1671            matches!(&err, CliError::Config(m) if m.contains("state")),
1672            "got: {err:?}"
1673        );
1674    }
1675
1676    #[test]
1677    fn allows_now_token_in_transform_config() {
1678        // `${now.*}` in a transform is resolved per invocation by the executor
1679        // (#568), so expand must accept it — like source/sink configs.
1680        let c = cfg(r#"
1681version: 1
1682pipeline:
1683  source: { type: rest, config: { base_url: https://x } }
1684  sink:   { type: jsonl, config: { path: ./o } }
1685  transforms:
1686    - type: set
1687      config: { values: { ts: "${now.datetime}" } }
1688"#);
1689        assert_eq!(expand(&c).unwrap().len(), 1);
1690    }
1691
1692    #[test]
1693    fn allows_reserved_builtin_tokens_in_transform_config() {
1694        // Reserved runtime built-ins (`${now.*}`, `${window.*}`, `${backfill.*}`,
1695        // `${bookmark}`, `${job_id}`, `${partition.*}`) are accepted in a
1696        // transform's config, exercising the check_refs guard chain (#568).
1697        let c = cfg(r#"
1698version: 1
1699pipeline:
1700  source: { type: rest, config: { base_url: https://x } }
1701  sink:   { type: jsonl, config: { path: ./o } }
1702  transforms:
1703    - type: set
1704      config: { values: { a: "${now.date}", b: "${window.from}", c: "${backfill.start}", d: "${bookmark}", e: "${job_id}", f: "${partition.id}" } }
1705"#);
1706        assert_eq!(expand(&c).unwrap().len(), 1);
1707    }
1708
1709    #[test]
1710    fn rejects_unknown_id_token_in_transform_config() {
1711        // An unknown id (not `now`/`backfill`/… and not a declared row) in a
1712        // transform still fails — it would leak as a literal at runtime.
1713        let c = cfg(r#"
1714version: 1
1715pipeline:
1716  source: { type: rest, config: { base_url: https://x } }
1717  sink:   { type: jsonl, config: { path: ./o } }
1718  transforms:
1719    - type: set
1720      config: { values: { who: "${nobody.name}" } }
1721"#);
1722        let err = expand(&c).unwrap_err();
1723        assert!(
1724            matches!(&err, CliError::UnknownInterpolationId { id, .. } if id == "nobody"),
1725            "got: {err:?}"
1726        );
1727    }
1728
1729    #[test]
1730    fn allows_flow_capture_token_in_source_config() {
1731        // A `type: flow` provider captures `session_id`; a source body may then
1732        // reference `${session_id}`, substituted per request by the connector
1733        // (#567). Expand must accept the token rather than rejecting it.
1734        let c = cfg(r#"
1735version: 1
1736auth:
1737  intacct:
1738    type: flow
1739    config:
1740      steps:
1741        - request: { url: "https://x/login", method: POST }
1742          capture: { session_id: "$.sessionid" }
1743      apply: []
1744pipeline:
1745  source:
1746    type: xml
1747    config:
1748      base_url: "https://x"
1749      path: /gw
1750      body: "<r><sessionid>${session_id}</sessionid></r>"
1751      auth: { ref: intacct }
1752  sink: { type: jsonl, config: { path: ./o } }
1753"#);
1754        assert_eq!(expand(&c).unwrap().len(), 1);
1755    }
1756
1757    #[test]
1758    fn rejects_capture_token_without_a_declaring_flow_provider() {
1759        // The same token with no flow provider declaring it is still an unknown
1760        // id — the allowance is scoped to declared captures.
1761        let c = cfg(r#"
1762version: 1
1763pipeline:
1764  source:
1765    type: xml
1766    config:
1767      base_url: "https://x"
1768      path: /gw
1769      body: "<r><sessionid>${session_id}</sessionid></r>"
1770  sink: { type: jsonl, config: { path: ./o } }
1771"#);
1772        let err = expand(&c).unwrap_err();
1773        assert!(
1774            matches!(&err, CliError::UnknownInterpolationId { id, .. } if id == "session_id"),
1775            "got: {err:?}"
1776        );
1777    }
1778
1779    #[test]
1780    fn allows_runtime_token_in_source_and_sink_configs() {
1781        // The same tokens remain valid in source/sink configs (regression guard
1782        // that M2's rejection didn't over-reach).
1783        let c = cfg(r#"
1784version: 1
1785pipeline:
1786  source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
1787  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1788"#);
1789        let nodes = expand(&c).unwrap();
1790        assert_eq!(nodes.len(), 1);
1791    }
1792
1793    #[test]
1794    fn merges_row_overrides_into_pipeline_source() {
1795        let c = cfg(r#"
1796version: 1
1797pipeline:
1798  source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
1799  sink:   { type: jsonl, config: { path: ./o } }
1800matrix:
1801  - id: users
1802    source: { config: { path: /v1/users, headers: { b: 2 } } }
1803"#);
1804        let nodes = expand(&c).unwrap();
1805        assert_eq!(nodes[0].id, "users");
1806        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1807        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1808        assert_eq!(nodes[0].source.config["headers"]["a"], 1);
1809        assert_eq!(nodes[0].source.config["headers"]["b"], 2);
1810    }
1811
1812    #[test]
1813    fn errors_on_unknown_parent() {
1814        let c = cfg(r#"
1815version: 1
1816pipeline:
1817  source: { type: rest, config: {} }
1818  sink:   { type: jsonl, config: { path: ./o } }
1819matrix:
1820  - id: child
1821    parent: nobody
1822"#);
1823        assert!(matches!(
1824            expand(&c).unwrap_err(),
1825            CliError::UnknownParent { .. }
1826        ));
1827    }
1828
1829    #[test]
1830    fn errors_on_duplicate_ids() {
1831        let c = cfg(r#"
1832version: 1
1833pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1834matrix:
1835  - { id: x }
1836  - { id: x }
1837"#);
1838        assert!(matches!(
1839            expand(&c).unwrap_err(),
1840            CliError::DuplicateRowId { .. }
1841        ));
1842    }
1843
1844    #[test]
1845    fn errors_on_reserved_id() {
1846        let c = cfg(r#"
1847version: 1
1848pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1849matrix:
1850  - { id: env }
1851"#);
1852        assert!(matches!(
1853            expand(&c).unwrap_err(),
1854            CliError::ReservedRowId { .. }
1855        ));
1856    }
1857
1858    #[test]
1859    fn bookmark_token_is_reserved_and_passes_through() {
1860        // `${bookmark}` is consumed inside a source's `replication_bind.template`
1861        // (#513); expand must treat it as a reserved deferred id, not reject it
1862        // as an unknown interpolation reference.
1863        let c = cfg(r#"
1864version: 1
1865pipeline:
1866  source:
1867    type: rest
1868    config:
1869      base_url: https://x
1870      replication_bind: { into: query, name: since, template: "gt ${bookmark}" }
1871  sink: { type: jsonl, config: { path: ./o } }
1872"#);
1873        let nodes = expand(&c).unwrap();
1874        assert_eq!(nodes.len(), 1);
1875    }
1876
1877    #[test]
1878    fn job_id_token_is_reserved_and_passes_through() {
1879        // `${job_id}` is consumed inside a source's `async_job` block (#514);
1880        // expand must treat it as a reserved deferred id.
1881        let c = cfg(r#"
1882version: 1
1883pipeline:
1884  source:
1885    type: rest
1886    config:
1887      base_url: https://x
1888      async_job: { submit: { url: /jobs }, job_id: "$.id", poll: { url: "/jobs/${job_id}" }, status: { path: "$.s", success: [Done] }, fetch: { url: "/jobs/${job_id}/r" } }
1889  sink: { type: jsonl, config: { path: ./o } }
1890"#);
1891        let nodes = expand(&c).unwrap();
1892        assert_eq!(nodes.len(), 1);
1893    }
1894
1895    #[test]
1896    fn window_token_is_reserved_and_passes_through() {
1897        // `${window}` is consumed inside a source's `window.{lower,upper}.template`
1898        // (#527); expand must treat it as a reserved deferred id, not a ref.
1899        let c = cfg(r#"
1900version: 1
1901pipeline:
1902  source:
1903    type: rest
1904    config:
1905      base_url: https://x
1906      path: /report
1907      replication_method: incremental
1908      replication_key: updated_at
1909      start_replication_value: "2024-01-01"
1910      window:
1911        step: 30d
1912        lower: { into: query, name: start_date, template: "${window}", format: date }
1913        upper: { into: query, name: end_date, template: "${window}", format: date }
1914  sink: { type: jsonl, config: { path: ./o } }
1915"#);
1916        let nodes = expand(&c).unwrap();
1917        assert_eq!(nodes.len(), 1);
1918    }
1919
1920    #[test]
1921    fn errors_on_self_parent_cycle() {
1922        let c = cfg(r#"
1923version: 1
1924pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1925matrix:
1926  - { id: a, parent: a }
1927"#);
1928        assert!(matches!(
1929            expand(&c).unwrap_err(),
1930            CliError::ParentCycle { .. }
1931        ));
1932    }
1933
1934    #[test]
1935    fn errors_on_two_node_cycle() {
1936        let c = cfg(r#"
1937version: 1
1938pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1939matrix:
1940  - { id: a, parent: b }
1941  - { id: b, parent: a }
1942"#);
1943        assert!(matches!(
1944            expand(&c).unwrap_err(),
1945            CliError::ParentCycle { .. }
1946        ));
1947    }
1948
1949    #[test]
1950    fn errors_on_unknown_dependency() {
1951        let c = cfg(r#"
1952version: 1
1953pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1954matrix:
1955  - { id: facts, depends_on: [nobody] }
1956"#);
1957        match expand(&c).unwrap_err() {
1958            CliError::UnknownDependency { id, depends_on } => {
1959                assert_eq!(id, "facts");
1960                assert_eq!(depends_on, "nobody");
1961            }
1962            other => panic!("expected UnknownDependency, got {other:?}"),
1963        }
1964    }
1965
1966    #[test]
1967    fn errors_on_self_dependency() {
1968        let c = cfg(r#"
1969version: 1
1970pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1971matrix:
1972  - { id: a, depends_on: [a] }
1973"#);
1974        match expand(&c).unwrap_err() {
1975            CliError::DependencyCycle { ids } => assert_eq!(ids, vec!["a".to_string()]),
1976            other => panic!("expected DependencyCycle, got {other:?}"),
1977        }
1978    }
1979
1980    #[test]
1981    fn errors_on_depends_on_cycle() {
1982        let c = cfg(r#"
1983version: 1
1984pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1985matrix:
1986  - { id: a, depends_on: [b] }
1987  - { id: b, depends_on: [a] }
1988"#);
1989        match expand(&c).unwrap_err() {
1990            CliError::DependencyCycle { ids } => {
1991                assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1992            }
1993            other => panic!("expected DependencyCycle, got {other:?}"),
1994        }
1995    }
1996
1997    #[test]
1998    fn errors_on_mixed_parent_depends_on_cycle() {
1999        // `a` is a child of `b` (parent edge b -> a) while `b` waits for `a`
2000        // (dependency edge a -> b). Neither the parent-only walk nor a
2001        // depends_on-only check sees this — only the combined graph does.
2002        let c = cfg(r#"
2003version: 1
2004pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
2005matrix:
2006  - { id: a, parent: b }
2007  - { id: b, depends_on: [a] }
2008"#);
2009        match expand(&c).unwrap_err() {
2010            CliError::DependencyCycle { ids } => {
2011                assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
2012            }
2013            other => panic!("expected DependencyCycle, got {other:?}"),
2014        }
2015    }
2016
2017    #[test]
2018    fn depends_on_is_recorded_and_deduped() {
2019        let c = cfg(r#"
2020version: 1
2021pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
2022matrix:
2023  - { id: dims }
2024  - { id: staging }
2025  - { id: facts, depends_on: [dims, staging, dims] }
2026"#);
2027        let nodes = expand(&c).unwrap();
2028        let facts = nodes.iter().find(|n| n.id == "facts").unwrap();
2029        assert_eq!(
2030            facts.depends_on,
2031            vec!["dims".to_string(), "staging".to_string()]
2032        );
2033        assert!(matches!(facts.role, NodeRole::Root));
2034        let dims = nodes.iter().find(|n| n.id == "dims").unwrap();
2035        assert!(dims.depends_on.is_empty());
2036    }
2037
2038    // ── Discovery-driven request matrix (#501) ─────────────────────────────
2039
2040    fn disc_cfg(matrix: &str) -> String {
2041        format!(
2042            r#"
2043version: 1
2044pipeline: {{ source: {{ type: rest, config: {{}} }}, sink: {{ type: jsonl, config: {{ path: ./o }} }} }}
2045matrix:
2046{matrix}
2047"#
2048        )
2049    }
2050
2051    #[test]
2052    fn discovery_and_product_roles_are_assigned() {
2053        let c = cfg(&disc_cfg(
2054            r#"  - id: subs
2055    discover:
2056      source: { type: rest, config: {} }
2057      select: "$.id"
2058      as: subsidiary_id
2059  - id: report
2060    for_each: [subs]"#,
2061        ));
2062        let nodes = expand(&c).unwrap();
2063        let subs = nodes.iter().find(|n| n.id == "subs").unwrap();
2064        match &subs.role {
2065            NodeRole::Discovery {
2066                select, as_alias, ..
2067            } => {
2068                assert_eq!(select, "$.id");
2069                assert_eq!(as_alias, "subsidiary_id");
2070            }
2071            other => panic!("expected Discovery, got {other:?}"),
2072        }
2073        let report = nodes.iter().find(|n| n.id == "report").unwrap();
2074        match &report.role {
2075            NodeRole::Product { dims, .. } => assert_eq!(dims, &vec!["subs".to_string()]),
2076            other => panic!("expected Product, got {other:?}"),
2077        }
2078        // The dim is folded into depends_on so readiness/skip/cycle reuse it.
2079        assert_eq!(report.depends_on, vec!["subs".to_string()]);
2080    }
2081
2082    #[test]
2083    fn chained_discover_without_collect_is_rejected() {
2084        // #531: a `discover:` row with `for_each:` must set `collect: true`.
2085        let c = cfg(&disc_cfg(
2086            r#"  - id: types
2087    discover: { source: { type: rest, config: {} }, select: "$.name", as: name }
2088  - id: props
2089    for_each: [types]
2090    discover: { source: { type: rest, config: {} }, select: "$.name", as: name }"#,
2091        ));
2092        let err = expand(&c).unwrap_err().to_string();
2093        assert!(err.contains("collect: true"), "{err}");
2094    }
2095
2096    #[test]
2097    fn collect_without_for_each_is_rejected() {
2098        // #531: `collect: true` is meaningless without an upstream `for_each:`.
2099        let c = cfg(&disc_cfg(
2100            r#"  - id: types
2101    discover: { source: { type: rest, config: {} }, select: "$.name", as: name, collect: true }"#,
2102        ));
2103        let err = expand(&c).unwrap_err().to_string();
2104        assert!(err.contains("requires `for_each:`"), "{err}");
2105    }
2106
2107    #[test]
2108    fn chained_discovery_roles_and_deps_are_wired() {
2109        // #531: types → props (chained, collected) → records (product injecting props).
2110        let c = cfg(&disc_cfg(
2111            r#"  - id: types
2112    discover: { source: { type: rest, config: {} }, select: "$.name", as: name }
2113  - id: props
2114    for_each: [types]
2115    discover:
2116      source: { type: rest, config: { path: "/props/${types.name}" } }
2117      select: "$.name"
2118      as: name
2119      collect: true
2120  - id: records
2121    for_each: [types]
2122    source: { type: rest, config: { path: "/obj/${types.name}", query_params: { properties: "${props.name}" } } }"#,
2123        ));
2124        let nodes = expand(&c).unwrap();
2125        // props: a chained Discovery (collect) fanning out over [types].
2126        let props = nodes.iter().find(|n| n.id == "props").unwrap();
2127        match &props.role {
2128            NodeRole::Discovery { collect, dims, .. } => {
2129                assert!(*collect);
2130                assert_eq!(dims, &vec!["types".to_string()]);
2131            }
2132            other => panic!("expected chained Discovery, got {other:?}"),
2133        }
2134        assert_eq!(props.depends_on, vec!["types".to_string()]);
2135        // records: a Product over [types] that injects the collected `props`.
2136        let records = nodes.iter().find(|n| n.id == "records").unwrap();
2137        match &records.role {
2138            NodeRole::Product { dims, collected } => {
2139                assert_eq!(dims, &vec!["types".to_string()]);
2140                assert_eq!(collected, &vec!["props".to_string()]);
2141            }
2142            other => panic!("expected Product, got {other:?}"),
2143        }
2144        // records depends on both the fan-out dim and the collected discovery.
2145        assert!(records.depends_on.contains(&"types".to_string()));
2146        assert!(records.depends_on.contains(&"props".to_string()));
2147    }
2148
2149    #[test]
2150    fn chained_discovery_cycle_is_rejected() {
2151        // #531: two chained discoveries fanning out over each other form a cycle.
2152        let c = cfg(&disc_cfg(
2153            r#"  - id: a
2154    for_each: [b]
2155    discover: { source: { type: rest, config: {} }, select: "$.name", as: name, collect: true }
2156  - id: b
2157    for_each: [a]
2158    discover: { source: { type: rest, config: {} }, select: "$.name", as: name, collect: true }"#,
2159        ));
2160        let err = expand(&c).unwrap_err().to_string();
2161        assert!(err.to_lowercase().contains("cycle"), "{err}");
2162    }
2163
2164    #[test]
2165    fn discover_with_sink_is_rejected() {
2166        let c = cfg(&disc_cfg(
2167            r#"  - id: a
2168    discover: { source: { type: rest, config: {} }, select: "$.id", as: x }
2169    sink: { type: jsonl, config: { path: ./o } }"#,
2170        ));
2171        let err = expand(&c).unwrap_err().to_string();
2172        assert!(err.contains("has no sink"), "{err}");
2173    }
2174
2175    #[test]
2176    fn for_each_on_non_discovery_row_is_rejected() {
2177        let c = cfg(&disc_cfg(
2178            r#"  - id: plain
2179  - id: report
2180    for_each: [plain]"#,
2181        ));
2182        let err = expand(&c).unwrap_err().to_string();
2183        assert!(err.contains("is not a `discover:` row"), "{err}");
2184    }
2185
2186    #[test]
2187    fn for_each_unknown_row_is_rejected() {
2188        let c = cfg(&disc_cfg(
2189            r#"  - id: report
2190    for_each: [ghost]"#,
2191        ));
2192        let err = expand(&c).unwrap_err().to_string();
2193        assert!(err.contains("unknown row 'ghost'"), "{err}");
2194    }
2195
2196    #[test]
2197    fn for_each_with_parent_is_rejected() {
2198        let c = cfg(&disc_cfg(
2199            r#"  - id: subs
2200    discover: { source: { type: rest, config: {} }, select: "$.id", as: x }
2201  - id: p
2202  - id: report
2203    parent: p
2204    for_each: [subs]"#,
2205        ));
2206        let err = expand(&c).unwrap_err().to_string();
2207        assert!(err.contains("cannot be combined"), "{err}");
2208    }
2209
2210    #[test]
2211    fn discover_bad_alias_is_rejected() {
2212        let c = cfg(&disc_cfg(
2213            r#"  - id: a
2214    discover: { source: { type: rest, config: {} }, select: "$.id", as: "Bad Alias" }"#,
2215        ));
2216        let err = expand(&c).unwrap_err().to_string();
2217        assert!(err.contains("must match"), "{err}");
2218    }
2219
2220    #[test]
2221    fn discovery_source_ref_resolves_named_template() {
2222        let c = cfg(r#"
2223version: 1
2224pipeline:
2225  sources:
2226    api: { type: rest, config: { base_url: https://x, path: /list } }
2227  sink: { type: jsonl, config: { path: ./o } }
2228matrix:
2229  - id: subs
2230    discover:
2231      source: { ref: api, config: { path: /subsidiaries } }
2232      select: "$.id"
2233      as: sid
2234  - id: report
2235    for_each: [subs]
2236    source: { ref: api }
2237"#);
2238        let nodes = expand(&c).unwrap();
2239        let subs = nodes.iter().find(|n| n.id == "subs").unwrap();
2240        // The named template resolved (kind rest) and the override applied.
2241        assert_eq!(subs.source.kind, "rest");
2242        assert_eq!(subs.source.config["path"], "/subsidiaries");
2243        assert_eq!(subs.source.config["base_url"], "https://x");
2244    }
2245
2246    #[test]
2247    fn depends_on_may_target_a_child_row() {
2248        // Waiting on a per-record fan-out row is legal: the dependent starts
2249        // only after every one of the child's invocations completes.
2250        let c = cfg(r#"
2251version: 1
2252pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
2253matrix:
2254  - { id: users }
2255  - { id: posts, parent: users }
2256  - { id: rollup, depends_on: [posts] }
2257"#);
2258        let nodes = expand(&c).unwrap();
2259        let rollup = nodes.iter().find(|n| n.id == "rollup").unwrap();
2260        assert_eq!(rollup.depends_on, vec!["posts".to_string()]);
2261    }
2262
2263    #[test]
2264    fn errors_on_unknown_interpolation_id() {
2265        let c = cfg(r#"
2266version: 1
2267pipeline:
2268  source: { type: rest, config: { url: "https://x/${nobody.id}" } }
2269  sink:   { type: jsonl, config: { path: ./o } }
2270"#);
2271        assert!(matches!(
2272            expand(&c).unwrap_err(),
2273            CliError::UnknownInterpolationId { .. }
2274        ));
2275    }
2276
2277    #[test]
2278    fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
2279        // Regression for #78/#39: `${env.foo}` has no colon, so it is a
2280        // deferred reference to id `env`, not a load-time `env:` directive.
2281        // The validator must reject it (as the runtime would), rather than
2282        // silently skipping it and letting `run` fail later.
2283        let c = cfg(r#"
2284version: 1
2285pipeline:
2286  source: { type: rest, config: { url: "https://x/${env.foo}" } }
2287  sink:   { type: jsonl, config: { path: ./o } }
2288"#);
2289        match expand(&c).unwrap_err() {
2290            CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
2291            other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
2292        }
2293    }
2294
2295    #[test]
2296    fn accepts_id_path_when_referenced_row_exists() {
2297        let c = cfg(r#"
2298version: 1
2299pipeline:
2300  source: { type: rest, config: {} }
2301  sink:   { type: jsonl, config: { path: ./o } }
2302matrix:
2303  - id: users
2304  - id: posts
2305    parent: users
2306    source: { config: { path: "/v1/users/${users.id}/posts" } }
2307"#);
2308        let nodes = expand(&c).unwrap();
2309        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
2310        assert_eq!(posts.deferred_refs.len(), 1);
2311        assert_eq!(posts.deferred_refs[0].referenced_id, "users");
2312        assert_eq!(posts.deferred_refs[0].dotted_path, "id");
2313    }
2314
2315    #[test]
2316    fn nested_referenced_path_resolves() {
2317        let c = cfg(r#"
2318version: 1
2319pipeline:
2320  source: { type: rest, config: {} }
2321  sink:   { type: jsonl, config: { path: ./o } }
2322matrix:
2323  - id: users
2324  - id: addrs
2325    parent: users
2326    source: { config: { path: "/users/${users.addr.city}/addr" } }
2327"#);
2328        let nodes = expand(&c).unwrap();
2329        let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
2330        assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
2331    }
2332
2333    #[test]
2334    fn roots_come_before_children_in_order() {
2335        let c = cfg(r#"
2336version: 1
2337pipeline:
2338  source: { type: rest, config: {} }
2339  sink:   { type: jsonl, config: { path: ./o } }
2340matrix:
2341  - id: posts
2342    parent: users
2343  - id: users
2344"#);
2345        let nodes = expand(&c).unwrap();
2346        let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
2347        let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
2348        assert!(users_idx < posts_idx, "users must precede posts");
2349    }
2350
2351    #[test]
2352    fn child_node_has_parent_role() {
2353        let c = cfg(r#"
2354version: 1
2355pipeline:
2356  source: { type: rest, config: {} }
2357  sink:   { type: jsonl, config: { path: ./o } }
2358matrix:
2359  - id: users
2360  - id: posts
2361    parent: users
2362    parent_key: user_id
2363"#);
2364        let nodes = expand(&c).unwrap();
2365        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
2366        match &posts.role {
2367            NodeRole::Child {
2368                parent_id,
2369                parent_key,
2370            } => {
2371                assert_eq!(parent_id, "users");
2372                assert_eq!(parent_key, "user_id");
2373            }
2374            other => panic!("expected Child, got {other:?}"),
2375        }
2376    }
2377
2378    #[test]
2379    fn expand_rejects_zero_per_page_budget() {
2380        let yaml = r#"
2381version: 1
2382pipeline:
2383  source: { type: rest, config: {} }
2384  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2385  dlq:
2386    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2387    max_failures_per_page: 0
2388"#;
2389        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2390        let err = expand(&cfg).unwrap_err();
2391        assert!(matches!(
2392            err,
2393            CliError::InvalidDlqBudget {
2394                field: "max_failures_per_page"
2395            }
2396        ));
2397    }
2398
2399    #[test]
2400    fn expand_rejects_zero_total_budget() {
2401        let yaml = r#"
2402version: 1
2403pipeline:
2404  source: { type: rest, config: {} }
2405  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2406  dlq:
2407    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2408    max_failures_total: 0
2409"#;
2410        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2411        let err = expand(&cfg).unwrap_err();
2412        assert!(matches!(
2413            err,
2414            CliError::InvalidDlqBudget {
2415                field: "max_failures_total"
2416            }
2417        ));
2418    }
2419
2420    #[test]
2421    fn expand_rejects_unknown_dlq_sink_kind() {
2422        let yaml = r#"
2423version: 1
2424pipeline:
2425  source: { type: rest, config: {} }
2426  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2427  dlq:
2428    sink: { type: not_a_sink, config: {} }
2429"#;
2430        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2431        let err = expand(&cfg).unwrap_err();
2432        assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
2433    }
2434
2435    #[cfg(feature = "quality")]
2436    #[test]
2437    fn expand_rejects_quarantine_without_dlq() {
2438        // A quality check with `on_failure: quarantine` needs a DLQ to route to.
2439        // `expand` must reject the config so `faucet validate` fails fast.
2440        let yaml = r#"
2441version: 1
2442pipeline:
2443  source: { type: rest, config: {} }
2444  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2445  quality:
2446    record:
2447      - { type: not_null, field: id, on_failure: quarantine }
2448"#;
2449        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2450        let err = expand(&cfg).unwrap_err();
2451        match err {
2452            CliError::Config(msg) => {
2453                assert!(msg.contains("quarantine"), "{msg}");
2454                assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
2455            }
2456            other => panic!("expected Config error, got {other:?}"),
2457        }
2458    }
2459
2460    #[cfg(feature = "quality")]
2461    #[test]
2462    fn expand_accepts_quarantine_with_dlq() {
2463        let yaml = r#"
2464version: 1
2465pipeline:
2466  source: { type: rest, config: {} }
2467  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2468  dlq:
2469    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2470  quality:
2471    record:
2472      - { type: not_null, field: id, on_failure: quarantine }
2473"#;
2474        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2475        let nodes = expand(&cfg).unwrap();
2476        assert_eq!(nodes.len(), 1);
2477        let q = nodes[0]
2478            .quality
2479            .as_ref()
2480            .expect("quality threaded onto node");
2481        assert_eq!(q.record.len(), 1);
2482    }
2483
2484    #[cfg(feature = "quality")]
2485    #[test]
2486    fn expand_accepts_abort_quality_without_dlq() {
2487        // `on_failure: abort` does not route to a DLQ, so no DLQ is required.
2488        let yaml = r#"
2489version: 1
2490pipeline:
2491  source: { type: rest, config: {} }
2492  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2493  quality:
2494    record:
2495      - { type: not_null, field: id, on_failure: abort }
2496"#;
2497        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2498        let nodes = expand(&cfg).unwrap();
2499        assert!(nodes[0].quality.is_some());
2500    }
2501
2502    #[cfg(feature = "contract")]
2503    #[test]
2504    fn expand_rejects_contract_quarantine_without_dlq() {
2505        let yaml = r#"
2506version: 1
2507pipeline:
2508  source: { type: rest, config: {} }
2509  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2510  contract:
2511    version: "1.0.0"
2512    on_breach: quarantine
2513    fields:
2514      - { name: id, type: integer }
2515"#;
2516        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2517        let err = expand(&cfg).unwrap_err();
2518        match err {
2519            CliError::Config(msg) => {
2520                assert!(msg.contains("on_breach: quarantine"), "{msg}");
2521                assert!(msg.contains("dlq"), "{msg}");
2522            }
2523            other => panic!("expected Config error, got {other:?}"),
2524        }
2525    }
2526
2527    #[cfg(feature = "contract")]
2528    #[test]
2529    fn expand_accepts_contract_quarantine_with_dlq() {
2530        let yaml = r#"
2531version: 1
2532pipeline:
2533  source: { type: rest, config: {} }
2534  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2535  dlq:
2536    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
2537  contract:
2538    version: "1.0.0"
2539    on_breach: quarantine
2540    fields:
2541      - { name: id, type: integer }
2542"#;
2543        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2544        let nodes = expand(&cfg).unwrap();
2545        assert_eq!(nodes.len(), 1);
2546        let c = nodes[0]
2547            .contract
2548            .as_ref()
2549            .expect("contract threaded onto node");
2550        assert_eq!(c.version, "1.0.0");
2551        assert_eq!(c.fields.len(), 1);
2552    }
2553
2554    #[cfg(feature = "contract")]
2555    #[test]
2556    fn expand_accepts_contract_fail_without_dlq() {
2557        // `on_breach: fail` (the default) does not route to a DLQ.
2558        let yaml = r#"
2559version: 1
2560pipeline:
2561  source: { type: rest, config: {} }
2562  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2563  contract:
2564    version: "1.0.0"
2565    fields:
2566      - { name: id, type: integer }
2567"#;
2568        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2569        let nodes = expand(&cfg).unwrap();
2570        assert!(nodes[0].contract.is_some());
2571    }
2572
2573    #[cfg(feature = "contract")]
2574    #[test]
2575    fn expand_rejects_malformed_contract() {
2576        // A bad regex must surface at expand time (load-time), not mid-run.
2577        let yaml = r#"
2578version: 1
2579pipeline:
2580  source: { type: rest, config: {} }
2581  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2582  contract:
2583    version: "1.0.0"
2584    fields:
2585      - { name: email, type: string, pattern: "[invalid" }
2586"#;
2587        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2588        let err = expand(&cfg).unwrap_err();
2589        match err {
2590            CliError::Config(msg) => assert!(msg.contains("invalid pattern"), "{msg}"),
2591            other => panic!("expected Config error, got {other:?}"),
2592        }
2593    }
2594
2595    #[test]
2596    fn legacy_singular_source_resolves_as_default_template() {
2597        let c = cfg(r#"
2598version: 1
2599pipeline:
2600  source: { type: rest, config: { base_url: https://x } }
2601  sink:   { type: jsonl, config: { path: ./o } }
2602"#);
2603        let nodes = expand(&c).unwrap();
2604        assert_eq!(nodes[0].source.kind, "rest");
2605        assert_eq!(nodes[0].source.config["base_url"], "https://x");
2606    }
2607
2608    #[test]
2609    fn row_with_ref_picks_named_template() {
2610        let c = cfg(r#"
2611version: 1
2612pipeline:
2613  sources:
2614    users_api: { type: rest, config: { base_url: https://x } }
2615  sinks:
2616    archive:   { type: jsonl, config: { path: ./out } }
2617matrix:
2618  - id: load_users
2619    source:
2620      ref: users_api
2621      config: { path: /v1/users }
2622    sink:
2623      ref: archive
2624      config: { path: ./users.jsonl }
2625"#);
2626        let nodes = expand(&c).unwrap();
2627        assert_eq!(nodes[0].source.kind, "rest");
2628        assert_eq!(nodes[0].source.config["base_url"], "https://x");
2629        assert_eq!(nodes[0].source.config["path"], "/v1/users");
2630        assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
2631    }
2632
2633    #[test]
2634    fn row_without_ref_falls_back_to_default_template() {
2635        let c = cfg(r#"
2636version: 1
2637pipeline:
2638  source: { type: rest, config: { base_url: https://x } }
2639  sink:   { type: jsonl, config: { path: ./o } }
2640matrix:
2641  - id: users
2642    source: { config: { path: /v1/users } }
2643"#);
2644        let nodes = expand(&c).unwrap();
2645        assert_eq!(nodes[0].source.kind, "rest");
2646        assert_eq!(nodes[0].source.config["path"], "/v1/users");
2647    }
2648
2649    #[test]
2650    fn unknown_template_ref_errors_with_known_list() {
2651        let c = cfg(r#"
2652version: 1
2653pipeline:
2654  sources:
2655    a: { type: rest, config: {} }
2656    b: { type: rest, config: {} }
2657  sinks:
2658    s: { type: jsonl, config: { path: ./o } }
2659matrix:
2660  - id: x
2661    source: { ref: c }
2662    sink: { ref: s }
2663"#);
2664        let err = expand(&c).unwrap_err();
2665        match err {
2666            CliError::UnknownTemplate {
2667                kind,
2668                name,
2669                row_id,
2670                known,
2671            } => {
2672                assert_eq!(kind, "source");
2673                assert_eq!(name, "c");
2674                assert_eq!(row_id, "x");
2675                assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
2676            }
2677            other => panic!("expected UnknownTemplate, got {other:?}"),
2678        }
2679    }
2680
2681    #[test]
2682    fn missing_default_template_errors() {
2683        // No singular `source:` and no `sources.default` — a row without a ref
2684        // has nowhere to go.
2685        let c = cfg(r#"
2686version: 1
2687pipeline:
2688  sources:
2689    users_api: { type: rest, config: {} }
2690  sink: { type: jsonl, config: { path: ./o } }
2691matrix:
2692  - id: x
2693    source: { config: { path: /v1 } }
2694"#);
2695        let err = expand(&c).unwrap_err();
2696        match err {
2697            CliError::MissingTemplate { kind, row_id } => {
2698                assert_eq!(kind, "source");
2699                assert_eq!(row_id, "x");
2700            }
2701            other => panic!("expected MissingTemplate, got {other:?}"),
2702        }
2703    }
2704
2705    #[test]
2706    fn duplicate_default_template_errors() {
2707        // Defining both legacy `source:` and `sources.default:` is a conflict.
2708        let c = cfg(r#"
2709version: 1
2710pipeline:
2711  source: { type: rest, config: {} }
2712  sources:
2713    default: { type: rest, config: {} }
2714  sink: { type: jsonl, config: { path: ./o } }
2715"#);
2716        let err = expand(&c).unwrap_err();
2717        match err {
2718            CliError::DuplicateTemplate { kind, name } => {
2719                assert_eq!(kind, "source");
2720                assert_eq!(name, "default");
2721            }
2722            other => panic!("expected DuplicateTemplate, got {other:?}"),
2723        }
2724    }
2725
2726    #[test]
2727    fn row_can_override_template_kind() {
2728        let c = cfg(r#"
2729version: 1
2730pipeline:
2731  sources:
2732    api: { type: rest, config: { base_url: https://x } }
2733  sinks:
2734    out: { type: jsonl, config: { path: ./o } }
2735matrix:
2736  - id: x
2737    source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
2738    sink: { ref: out }
2739"#);
2740        let nodes = expand(&c).unwrap();
2741        assert_eq!(nodes[0].source.kind, "graphql");
2742        assert_eq!(nodes[0].source.config["base_url"], "https://x");
2743        assert_eq!(nodes[0].source.config["query"], "{users{id}}");
2744    }
2745
2746    #[test]
2747    fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
2748        let yaml = r#"
2749version: 1
2750pipeline:
2751  source: { type: rest, config: {} }
2752  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2753  dlq:
2754    sink: { type: jsonl, config: { path: ./base.jsonl } }
2755matrix:
2756  - id: a
2757  - id: b
2758    dlq: null
2759  - id: c
2760    dlq:
2761      sink: { type: jsonl, config: { path: ./c.jsonl } }
2762      on_batch_error: dlq_all
2763"#;
2764        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2765        let nodes = expand(&cfg).unwrap();
2766        assert_eq!(nodes.len(), 3);
2767        // Row a inherits.
2768        assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
2769        assert_eq!(
2770            nodes[0]
2771                .dlq
2772                .as_ref()
2773                .unwrap()
2774                .sink
2775                .config
2776                .get("path")
2777                .unwrap(),
2778            "./base.jsonl"
2779        );
2780        // Row b is disabled.
2781        assert!(nodes[1].dlq.is_none());
2782        // Row c is replaced.
2783        assert_eq!(
2784            nodes[2].dlq.as_ref().unwrap().on_batch_error,
2785            OnBatchErrorSpec::DlqAll
2786        );
2787        assert_eq!(
2788            nodes[2]
2789                .dlq
2790                .as_ref()
2791                .unwrap()
2792                .sink
2793                .config
2794                .get("path")
2795                .unwrap(),
2796            "./c.jsonl"
2797        );
2798    }
2799
2800    #[test]
2801    fn multiple_rows_pick_different_templates() {
2802        let c = cfg(r#"
2803version: 1
2804pipeline:
2805  sources:
2806    users_api:  { type: rest, config: { base_url: https://users.example } }
2807    orders_api: { type: rest, config: { base_url: https://orders.example } }
2808  sinks:
2809    archive: { type: jsonl, config: { path: ./out } }
2810matrix:
2811  - id: load_users
2812    source: { ref: users_api, config: { path: /v1/users } }
2813    sink:   { ref: archive,   config: { path: ./users.jsonl } }
2814  - id: load_orders
2815    source: { ref: orders_api, config: { path: /v1/orders } }
2816    sink:   { ref: archive,    config: { path: ./orders.jsonl } }
2817"#);
2818        let nodes = expand(&c).unwrap();
2819        assert_eq!(nodes.len(), 2);
2820        let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
2821        let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
2822        assert_eq!(users.source.config["base_url"], "https://users.example");
2823        assert_eq!(users.source.config["path"], "/v1/users");
2824        assert_eq!(orders.source.config["base_url"], "https://orders.example");
2825        assert_eq!(orders.source.config["path"], "/v1/orders");
2826        // Both rows share the same sink template but pick different output paths.
2827        assert_eq!(users.sink.config["path"], "./users.jsonl");
2828        assert_eq!(orders.sink.config["path"], "./orders.jsonl");
2829    }
2830
2831    #[test]
2832    fn sink_template_with_transforms_errors_at_expand() {
2833        let yaml = r#"
2834version: 1
2835pipeline:
2836  source:
2837    type: rest
2838    config: {}
2839  sinks:
2840    bad:
2841      type: jsonl
2842      config: { destination: /tmp/x.jsonl }
2843      transforms:
2844        - { type: flatten, config: { separator: "_" } }
2845matrix:
2846  - id: row
2847    sink: { ref: bad }
2848"#;
2849        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2850            .unwrap();
2851        let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
2852        match err {
2853            crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
2854            other => panic!("expected TransformsOnSink, got {other:?}"),
2855        }
2856    }
2857
2858    #[test]
2859    fn sink_template_with_inherit_transforms_false_errors_at_expand() {
2860        let yaml = r#"
2861version: 1
2862pipeline:
2863  source:
2864    type: rest
2865    config: {}
2866  sinks:
2867    bad:
2868      type: jsonl
2869      config: { destination: /tmp/x.jsonl }
2870      inherit_transforms: false
2871matrix:
2872  - id: row
2873    sink: { ref: bad }
2874"#;
2875        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2876            .unwrap();
2877        let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
2878        match err {
2879            crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
2880            other => panic!("expected InheritTransformsOnSink, got {other:?}"),
2881        }
2882    }
2883
2884    fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
2885        transforms.iter().map(|t| t.kind.clone()).collect()
2886    }
2887
2888    #[test]
2889    fn three_layer_concat_default_inherit() {
2890        let yaml = r#"
2891version: 1
2892pipeline:
2893  transforms:
2894    - { type: flatten, config: { separator: "_" } }
2895  sources:
2896    s:
2897      type: rest
2898      config: {}
2899      transforms:
2900        - { type: keys_case, config: { mode: snake } }
2901  sink:
2902    type: jsonl
2903    config: { destination: /tmp/x.jsonl }
2904matrix:
2905  - id: row
2906    source: { ref: s }
2907    transforms:
2908      - { type: select, config: { fields: [id] } }
2909"#;
2910        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2911            .unwrap();
2912        let nodes = crate::expand::expand(&cfg).unwrap();
2913        assert_eq!(nodes.len(), 1);
2914        assert_eq!(
2915            kinds(&nodes[0].transforms),
2916            vec!["flatten", "keys_case", "select"]
2917        );
2918    }
2919
2920    #[test]
2921    fn source_inherit_false_drops_pipeline_layer() {
2922        let yaml = r#"
2923version: 1
2924pipeline:
2925  transforms:
2926    - { type: flatten, config: { separator: "_" } }
2927  sources:
2928    s:
2929      type: rest
2930      config: {}
2931      inherit_transforms: false
2932      transforms:
2933        - { type: keys_case, config: { mode: snake } }
2934  sink:
2935    type: jsonl
2936    config: { destination: /tmp/x.jsonl }
2937matrix:
2938  - id: row
2939    source: { ref: s }
2940    transforms:
2941      - { type: select, config: { fields: [id] } }
2942"#;
2943        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2944            .unwrap();
2945        let nodes = crate::expand::expand(&cfg).unwrap();
2946        assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
2947    }
2948
2949    #[test]
2950    fn row_inherit_false_drops_pipeline_and_source_layers() {
2951        let yaml = r#"
2952version: 1
2953pipeline:
2954  transforms:
2955    - { type: flatten, config: { separator: "_" } }
2956  sources:
2957    s:
2958      type: rest
2959      config: {}
2960      transforms:
2961        - { type: keys_case, config: { mode: snake } }
2962  sink:
2963    type: jsonl
2964    config: { destination: /tmp/x.jsonl }
2965matrix:
2966  - id: row
2967    source: { ref: s }
2968    inherit_transforms: false
2969    transforms:
2970      - { type: select, config: { fields: [id] } }
2971"#;
2972        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2973            .unwrap();
2974        let nodes = crate::expand::expand(&cfg).unwrap();
2975        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2976    }
2977
2978    #[test]
2979    fn both_inherit_false_yields_row_only() {
2980        let yaml = r#"
2981version: 1
2982pipeline:
2983  transforms:
2984    - { type: flatten, config: { separator: "_" } }
2985  sources:
2986    s:
2987      type: rest
2988      config: {}
2989      inherit_transforms: false
2990      transforms:
2991        - { type: keys_case, config: { mode: snake } }
2992  sink:
2993    type: jsonl
2994    config: { destination: /tmp/x.jsonl }
2995matrix:
2996  - id: row
2997    source: { ref: s }
2998    inherit_transforms: false
2999    transforms:
3000      - { type: select, config: { fields: [id] } }
3001"#;
3002        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
3003            .unwrap();
3004        let nodes = crate::expand::expand(&cfg).unwrap();
3005        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
3006    }
3007
3008    #[test]
3009    fn all_layers_omitted_yields_empty_transforms() {
3010        let yaml = r#"
3011version: 1
3012pipeline:
3013  source:
3014    type: rest
3015    config: {}
3016  sink:
3017    type: jsonl
3018    config: { destination: /tmp/x.jsonl }
3019matrix:
3020  - id: row
3021"#;
3022        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
3023            .unwrap();
3024        let nodes = crate::expand::expand(&cfg).unwrap();
3025        assert!(nodes[0].transforms.is_empty());
3026    }
3027
3028    #[test]
3029    fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
3030        // A root pipeline referencing ${now.date} must pass expand validation.
3031        let yaml = r#"
3032version: 1
3033pipeline:
3034  source: { type: rest, config: {} }
3035  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
3036"#;
3037        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3038        // expand must NOT raise UnknownInterpolationId for `now`.
3039        assert!(expand(&cfg).is_ok());
3040    }
3041
3042    #[test]
3043    fn now_is_a_reserved_row_id() {
3044        let yaml = r#"
3045version: 1
3046pipeline:
3047  source: { type: rest, config: {} }
3048  sink:   { type: jsonl, config: { path: ./o.jsonl } }
3049matrix:
3050  - id: now
3051"#;
3052        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3053        match expand(&cfg).unwrap_err() {
3054            CliError::ReservedRowId { id } => assert_eq!(id, "now"),
3055            other => panic!("expected ReservedRowId, got {other:?}"),
3056        }
3057    }
3058
3059    #[test]
3060    fn expand_rejects_invalid_adaptive_batch_size_at_load() {
3061        // Fail-fast: an invalid execution.adaptive_batch_size block must be
3062        // rejected by `expand` (the gate `faucet validate` uses), not only at
3063        // run time in the executor.
3064        let yaml = r#"
3065version: 1
3066pipeline:
3067  source: { type: rest, config: {} }
3068  sink:   { type: jsonl, config: { path: ./o.jsonl } }
3069execution:
3070  adaptive_batch_size:
3071    enabled: true
3072    min: 5000
3073    max: 100
3074"#;
3075        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3076        let err = expand(&cfg).unwrap_err();
3077        assert!(
3078            err.to_string().contains("adaptive_batch_size.min"),
3079            "expected adaptive validation error, got: {err}"
3080        );
3081    }
3082
3083    #[test]
3084    fn expand_accepts_valid_adaptive_batch_size() {
3085        let yaml = r#"
3086version: 1
3087pipeline:
3088  source: { type: rest, config: {} }
3089  sink:   { type: jsonl, config: { path: ./o.jsonl } }
3090execution:
3091  adaptive_batch_size:
3092    enabled: true
3093    min: 100
3094    max: 5000
3095    target_latency_ms: 500
3096"#;
3097        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3098        assert!(expand(&cfg).is_ok());
3099    }
3100
3101    // --- exactly-once delivery gate tests ---
3102
3103    #[test]
3104    fn exactly_once_rejects_non_cdc_source() {
3105        // rest→stdout with exactly_once must fail: rest is not replay-capable.
3106        let yaml = r#"
3107version: 1
3108delivery: exactly_once
3109pipeline:
3110  source: { type: rest, config: { base_url: https://x } }
3111  sink:   { type: stdout, config: {} }
3112  state:
3113    type: memory
3114    config: {}
3115"#;
3116        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3117        let err = expand(&cfg).unwrap_err();
3118        match &err {
3119            CliError::Config(msg) => {
3120                assert!(
3121                    msg.contains("rest"),
3122                    "expected source kind in error, got: {msg}"
3123                );
3124                assert!(
3125                    msg.contains("exactly_once") || msg.contains("not supported"),
3126                    "got: {msg}"
3127                );
3128            }
3129            other => panic!("expected Config error, got {other:?}"),
3130        }
3131    }
3132
3133    #[test]
3134    fn exactly_once_rejects_non_idempotent_sink() {
3135        // postgres-cdc→stdout: source is OK but stdout is not idempotent.
3136        let yaml = r#"
3137version: 1
3138delivery: exactly_once
3139pipeline:
3140  source: { type: postgres-cdc, config: {} }
3141  sink:   { type: stdout, config: {} }
3142  state:
3143    type: memory
3144    config: {}
3145"#;
3146        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3147        let err = expand(&cfg).unwrap_err();
3148        match &err {
3149            CliError::Config(msg) => {
3150                assert!(
3151                    msg.contains("stdout"),
3152                    "expected sink kind in error, got: {msg}"
3153                );
3154                assert!(
3155                    msg.contains("exactly_once") || msg.contains("not supported"),
3156                    "got: {msg}"
3157                );
3158            }
3159            other => panic!("expected Config error, got {other:?}"),
3160        }
3161    }
3162
3163    #[test]
3164    fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
3165        // postgres-cdc → sqlite + a *durable* state store → must expand
3166        // successfully. (Must not be `memory`: exactly-once needs cross-restart
3167        // durability — see `exactly_once_rejects_memory_state`.)
3168        let yaml = r#"
3169version: 1
3170delivery: exactly_once
3171pipeline:
3172  source: { type: postgres-cdc, config: {} }
3173  sink:   { type: sqlite, config: {} }
3174  state:
3175    type: file
3176    config: { path: "/tmp/faucet-eo-state.json" }
3177"#;
3178        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3179        let nodes = expand(&cfg).unwrap();
3180        assert_eq!(nodes.len(), 1);
3181        assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
3182        assert_eq!(
3183            nodes[0].delivery_guarantee,
3184            faucet_core::DeliveryGuarantee::EffectivelyOnce(
3185                faucet_core::EffectivelyOnceMechanism::AtomicWatermark
3186            )
3187        );
3188    }
3189
3190    #[test]
3191    fn exactly_once_accepted_via_keyed_upsert_with_any_source() {
3192        // rest → postgres with `write_mode: upsert` + `key`: accepted under
3193        // exactly_once via the keyed-upsert mechanism (#292) — no CDC source,
3194        // no state store required.
3195        let yaml = r#"
3196version: 1
3197delivery: exactly_once
3198pipeline:
3199  source: { type: rest, config: { base_url: https://x } }
3200  sink:
3201    type: postgres
3202    config:
3203      connection_url: "postgres://localhost/db"
3204      table_name: t
3205      column_mapping: auto_map
3206      write_mode: upsert
3207      key: [id]
3208"#;
3209        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3210        let nodes = expand(&cfg).unwrap();
3211        assert_eq!(
3212            nodes[0].delivery_guarantee,
3213            faucet_core::DeliveryGuarantee::EffectivelyOnce(
3214                faucet_core::EffectivelyOnceMechanism::KeyedUpsert
3215            )
3216        );
3217    }
3218
3219    #[test]
3220    fn exactly_once_kafka_source_accepted_with_atomic_sink() {
3221        // kafka → sqlite + durable state: the kafka source's offset bookmarks
3222        // qualify it for the atomic-watermark mechanism (#291).
3223        let yaml = r#"
3224version: 1
3225delivery: exactly_once
3226pipeline:
3227  source:
3228    type: kafka
3229    config: { brokers: "localhost:9092", topics: [t], group_id: g, max_messages: 10 }
3230  sink:   { type: sqlite, config: {} }
3231  state:
3232    type: file
3233    config: { path: "/tmp/faucet-eo-kafka-state.json" }
3234"#;
3235        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3236        let nodes = expand(&cfg).unwrap();
3237        assert_eq!(
3238            nodes[0].delivery_guarantee,
3239            faucet_core::DeliveryGuarantee::EffectivelyOnce(
3240                faucet_core::EffectivelyOnceMechanism::AtomicWatermark
3241            )
3242        );
3243    }
3244
3245    #[test]
3246    fn exactly_once_source_error_hints_keyed_upsert_for_capable_sink() {
3247        // rest → postgres (no write_mode): the source error should point at
3248        // the keyed-upsert alternative since postgres is upsert-capable.
3249        let yaml = r#"
3250version: 1
3251delivery: exactly_once
3252pipeline:
3253  source: { type: rest, config: { base_url: https://x } }
3254  sink:
3255    type: postgres
3256    config:
3257      connection_url: "postgres://localhost/db"
3258      table_name: t
3259      column_mapping: auto_map
3260  state:
3261    type: file
3262    config: { path: "/tmp/faucet-eo-hint-state.json" }
3263"#;
3264        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3265        let err = expand(&cfg).unwrap_err();
3266        match &err {
3267            CliError::Config(msg) => assert!(
3268                msg.contains("write_mode: upsert"),
3269                "expected keyed-upsert hint, got: {msg}"
3270            ),
3271            other => panic!("expected Config error, got {other:?}"),
3272        }
3273    }
3274
3275    #[test]
3276    fn derived_guarantee_is_at_least_once_by_default() {
3277        let yaml = r#"
3278version: 1
3279pipeline:
3280  source: { type: rest, config: { base_url: https://x } }
3281  sink:   { type: stdout, config: {} }
3282"#;
3283        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3284        let nodes = expand(&cfg).unwrap();
3285        assert_eq!(
3286            nodes[0].delivery_guarantee,
3287            faucet_core::DeliveryGuarantee::AtLeastOnce
3288        );
3289    }
3290
3291    #[test]
3292    fn exactly_once_rejects_memory_state() {
3293        // A non-durable `memory` store defeats the cross-restart watermark
3294        // guarantee, so it must be rejected at config-load (F24).
3295        let yaml = r#"
3296version: 1
3297delivery: exactly_once
3298pipeline:
3299  source: { type: postgres-cdc, config: {} }
3300  sink:   { type: sqlite, config: {} }
3301  state:
3302    type: memory
3303    config: {}
3304"#;
3305        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3306        let err = expand(&cfg).unwrap_err();
3307        match &err {
3308            CliError::Config(msg) => assert!(
3309                msg.contains("durable") && msg.contains("memory"),
3310                "expected durable/memory mention, got: {msg}"
3311            ),
3312            other => panic!("expected Config error, got {other:?}"),
3313        }
3314    }
3315
3316    #[test]
3317    fn exactly_once_rejects_missing_state_store() {
3318        // Valid CDC pair but no state block → must fail with "requires a state store".
3319        let yaml = r#"
3320version: 1
3321delivery: exactly_once
3322pipeline:
3323  source: { type: postgres-cdc, config: {} }
3324  sink:   { type: sqlite, config: {} }
3325"#;
3326        let cfg = parse_with_extension(yaml, "yaml").unwrap();
3327        let err = expand(&cfg).unwrap_err();
3328        match &err {
3329            CliError::Config(msg) => {
3330                assert!(
3331                    msg.contains("state store") || msg.contains("state"),
3332                    "expected state-store mention in error, got: {msg}"
3333                );
3334            }
3335            other => panic!("expected Config error, got {other:?}"),
3336        }
3337    }
3338
3339    #[test]
3340    fn rejects_upsert_on_unsupported_sink() {
3341        let c = cfg(r#"
3342version: 1
3343name: t
3344pipeline:
3345  source: { type: rest, config: { url: "http://x" } }
3346  sink:   { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
3347"#);
3348        let err = expand(&c).unwrap_err();
3349        let msg = format!("{err}");
3350        assert!(
3351            msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
3352            "{msg}"
3353        );
3354    }
3355
3356    #[test]
3357    fn rejects_upsert_without_key() {
3358        let c = cfg(r#"
3359version: 1
3360name: t
3361pipeline:
3362  source: { type: rest, config: { url: "http://x" } }
3363  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
3364"#);
3365        let err = expand(&c).unwrap_err();
3366        let msg = format!("{err}");
3367        assert!(msg.contains("key"), "{msg}");
3368    }
3369
3370    #[test]
3371    fn accepts_upsert_on_postgres_with_key() {
3372        let c = cfg(r#"
3373version: 1
3374name: t
3375pipeline:
3376  source: { type: rest, config: { url: "http://x" } }
3377  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
3378"#);
3379        assert!(expand(&c).is_ok());
3380    }
3381
3382    #[test]
3383    fn bigquery_upsert_passes_write_mode_gate() {
3384        let c = cfg(r#"
3385version: 1
3386name: t
3387pipeline:
3388  source: { type: rest, config: { url: "http://x" } }
3389  sink:   { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
3390"#);
3391        assert!(expand(&c).is_ok());
3392    }
3393
3394    #[test]
3395    fn accepts_append_by_default_on_any_sink() {
3396        let c = cfg(r#"
3397version: 1
3398name: t
3399pipeline:
3400  source: { type: rest, config: { url: "http://x" } }
3401  sink:   { type: jsonl, config: { path: "out.jsonl" } }
3402"#);
3403        assert!(expand(&c).is_ok());
3404    }
3405
3406    #[test]
3407    fn rejects_delete_without_key() {
3408        let c = cfg(r#"
3409version: 1
3410name: t
3411pipeline:
3412  source: { type: rest, config: { url: "http://x" } }
3413  sink:   { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
3414"#);
3415        let err = expand(&c).unwrap_err();
3416        let msg = format!("{err}");
3417        assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
3418    }
3419
3420    #[test]
3421    fn rejects_unknown_write_mode() {
3422        let c = cfg(r#"
3423version: 1
3424name: t
3425pipeline:
3426  source: { type: rest, config: { url: "http://x" } }
3427  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
3428"#);
3429        let err = expand(&c).unwrap_err();
3430        let msg = format!("{err}");
3431        assert!(
3432            msg.contains("unknown write_mode") && msg.contains("replace"),
3433            "{msg}"
3434        );
3435    }
3436
3437    #[test]
3438    fn overwrite_passes_on_capable_sink() {
3439        let c = cfg(r#"
3440version: 1
3441name: t
3442pipeline:
3443  source: { type: rest, config: { url: "http://x" } }
3444  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: overwrite } }
3445"#);
3446        assert!(
3447            expand(&c).is_ok(),
3448            "overwrite needs no key and postgres supports it"
3449        );
3450    }
3451
3452    #[test]
3453    fn scoped_overwrite_passes_on_postgres() {
3454        let c = cfg(r#"
3455version: 1
3456name: t
3457pipeline:
3458  source: { type: rest, config: { url: "http://x" } }
3459  sink:
3460    type: postgres
3461    config:
3462      connection_url: "postgres://x"
3463      table_name: t
3464      column_mapping: auto_map
3465      write_mode: overwrite
3466      scope: { window: { column: posting_date, from: "2024-06-01", to: "2024-07-01" } }
3467"#);
3468        assert!(expand(&c).is_ok(), "postgres supports scoped overwrite");
3469    }
3470
3471    #[test]
3472    fn rejects_scope_on_non_scoped_sink() {
3473        let c = cfg(r#"
3474version: 1
3475name: t
3476pipeline:
3477  source: { type: rest, config: { url: "http://x" } }
3478  sink:
3479    type: sqlite
3480    config:
3481      connection_url: "sqlite://x"
3482      table_name: t
3483      column_mapping: auto_map
3484      write_mode: overwrite
3485      scope: { window: { column: d, from: 1, to: 2 } }
3486"#);
3487        let msg = format!("{}", expand(&c).unwrap_err());
3488        assert!(
3489            msg.contains("scoped overwrite") && msg.contains("not supported"),
3490            "{msg}"
3491        );
3492    }
3493
3494    #[test]
3495    fn rejects_scope_without_overwrite_mode() {
3496        let c = cfg(r#"
3497version: 1
3498name: t
3499pipeline:
3500  source: { type: rest, config: { url: "http://x" } }
3501  sink:
3502    type: postgres
3503    config:
3504      connection_url: "postgres://x"
3505      table_name: t
3506      column_mapping: auto_map
3507      scope: { window: { column: d, from: 1, to: 2 } }
3508"#);
3509        let msg = format!("{}", expand(&c).unwrap_err());
3510        assert!(
3511            msg.contains("only valid with `write_mode: overwrite`"),
3512            "{msg}"
3513        );
3514    }
3515
3516    #[test]
3517    fn rejects_overwrite_on_unsupported_sink() {
3518        let c = cfg(r#"
3519version: 1
3520name: t
3521pipeline:
3522  source: { type: rest, config: { url: "http://x" } }
3523  sink:   { type: jsonl, config: { path: "out.jsonl", write_mode: overwrite } }
3524"#);
3525        let err = expand(&c).unwrap_err();
3526        let msg = format!("{err}");
3527        assert!(
3528            msg.contains("overwrite")
3529                && msg.contains("not supported")
3530                && msg.contains("overwrite sinks"),
3531            "{msg}"
3532        );
3533    }
3534
3535    #[test]
3536    fn rejects_overwrite_with_exactly_once() {
3537        let c = cfg(r#"
3538version: 1
3539name: t
3540delivery: exactly_once
3541pipeline:
3542  source: { type: rest, config: { url: "http://x" } }
3543  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: overwrite } }
3544  state:  { type: file, config: { path: "./s.json" } }
3545"#);
3546        let err = expand(&c).unwrap_err();
3547        let msg = format!("{err}");
3548        assert!(
3549            msg.contains("overwrite") && msg.contains("exactly_once"),
3550            "{msg}"
3551        );
3552    }
3553
3554    #[test]
3555    fn rejects_overwrite_with_schema_evolve() {
3556        let c = cfg(r#"
3557version: 1
3558name: t
3559pipeline:
3560  source: { type: rest, config: { url: "http://x" } }
3561  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: overwrite } }
3562  schema: { on_drift: evolve }
3563"#);
3564        let err = expand(&c).unwrap_err();
3565        let msg = format!("{err}");
3566        assert!(msg.contains("overwrite") && msg.contains("evolve"), "{msg}");
3567    }
3568
3569    #[test]
3570    fn rejects_poison_dlq_action_without_dlq() {
3571        let c = cfg(r#"
3572version: 1
3573pipeline:
3574  source: { type: rest, config: { base_url: https://x } }
3575  sink:   { type: jsonl, config: { path: ./o } }
3576resilience:
3577  poison: { max_row_attempts: 3, action: dlq }
3578"#);
3579        let err = expand(&c).unwrap_err();
3580        assert!(
3581            matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
3582            "got: {err:?}"
3583        );
3584    }
3585
3586    #[test]
3587    fn accepts_poison_dlq_action_with_dlq() {
3588        let c = cfg(r#"
3589version: 1
3590pipeline:
3591  source: { type: rest, config: { base_url: https://x } }
3592  sink:   { type: jsonl, config: { path: ./o } }
3593  dlq:
3594    sink: { type: jsonl, config: { path: ./dead.jsonl } }
3595resilience:
3596  poison: { max_row_attempts: 3, action: dlq }
3597"#);
3598        let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
3599        assert_eq!(nodes.len(), 1);
3600    }
3601
3602    #[test]
3603    fn accepts_poison_drop_action_without_dlq() {
3604        // action=drop discards rows in place, so no DLQ is required.
3605        let c = cfg(r#"
3606version: 1
3607pipeline:
3608  source: { type: rest, config: { base_url: https://x } }
3609  sink:   { type: jsonl, config: { path: ./o } }
3610resilience:
3611  poison: { max_row_attempts: 3, action: drop }
3612"#);
3613        let nodes = expand(&c).expect("poison.action=drop needs no dlq");
3614        assert_eq!(nodes.len(), 1);
3615    }
3616
3617    // --- schema-drift composition gate tests ---
3618
3619    #[test]
3620    fn evolve_on_non_evolvable_sink_rejected() {
3621        // jsonl is not evolution-capable; on_drift: evolve must fail.
3622        let c = cfg(r#"
3623version: 1
3624pipeline:
3625  source: { type: rest, config: { base_url: https://x } }
3626  sink:   { type: jsonl, config: { path: ./o.jsonl } }
3627  schema:
3628    on_drift: evolve
3629"#);
3630        let err = expand(&c).unwrap_err();
3631        match &err {
3632            CliError::Config(msg) => {
3633                assert!(
3634                    msg.contains("evolve"),
3635                    "expected evolve mention, got: {msg}"
3636                );
3637                assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
3638            }
3639            other => panic!("expected Config error, got {other:?}"),
3640        }
3641    }
3642
3643    #[test]
3644    fn quarantine_drift_without_dlq_rejected() {
3645        // on_drift: quarantine requires a dlq: block.
3646        let c = cfg(r#"
3647version: 1
3648pipeline:
3649  source: { type: rest, config: { base_url: https://x } }
3650  sink:   { type: postgres, config: {} }
3651  schema:
3652    on_drift: quarantine
3653"#);
3654        let err = expand(&c).unwrap_err();
3655        match &err {
3656            CliError::Config(msg) => {
3657                assert!(
3658                    msg.contains("quarantine"),
3659                    "expected quarantine mention, got: {msg}"
3660                );
3661                assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
3662            }
3663            other => panic!("expected Config error, got {other:?}"),
3664        }
3665    }
3666
3667    #[test]
3668    fn evolve_on_postgres_passes() {
3669        // postgres is evolution-capable; on_drift: evolve must expand.
3670        let c = cfg(r#"
3671version: 1
3672pipeline:
3673  source: { type: rest, config: { base_url: https://x } }
3674  sink:   { type: postgres, config: {} }
3675  schema:
3676    on_drift: evolve
3677"#);
3678        assert!(expand(&c).is_ok());
3679    }
3680}
3681
3682#[cfg(test)]
3683mod partition_tests {
3684    //! Row fan-out for the `partition:` block (#479).
3685    use super::*;
3686    use crate::config::PipelineConfig;
3687
3688    fn cfg(yaml: &str) -> PipelineConfig {
3689        PipelineConfig::from_text(yaml, std::path::Path::new("p.yaml")).expect("config parses")
3690    }
3691
3692    const SCOPED_SOURCE: &str = r#"
3693    type: rest
3694    config:
3695      base_url: "https://api.example.com"
3696      path: "/records?id_from=${partition.start}&id_to=${partition.end}""#;
3697
3698    fn doc(partition: &str, source: &str) -> String {
3699        format!(
3700            "version: 1\nname: p\npipeline:\n  source:{source}\n  sink:\n    type: jsonl\n    config:\n      path: ./out.jsonl\n{partition}"
3701        )
3702    }
3703
3704    #[test]
3705    fn a_partitioned_row_expands_into_one_node_per_chunk() {
3706        let nodes = expand(&cfg(&doc(
3707            "partition:\n  kind: integer\n  from: 0\n  to: 24\n  chunk_size: 10\n  bounds: inclusive\n",
3708            SCOPED_SOURCE,
3709        )))
3710        .expect("expand");
3711        assert_eq!(nodes.len(), 3, "24 values / 10 = 3 chunks");
3712        // Each node's source carries its own substituted range.
3713        let urls: Vec<String> = nodes
3714            .iter()
3715            .map(|n| n.source.config["path"].as_str().unwrap().to_string())
3716            .collect();
3717        assert!(urls[0].contains("id_from=0&id_to=9"), "{:?}", urls[0]);
3718        assert!(urls[1].contains("id_from=10&id_to=19"), "{:?}", urls[1]);
3719        assert!(urls[2].contains("id_from=20&id_to=24"), "{:?}", urls[2]);
3720    }
3721
3722    #[test]
3723    fn chunk_ids_are_distinct_and_namespaced_so_state_keys_cannot_collide() {
3724        let nodes = expand(&cfg(&doc(
3725            "partition:\n  kind: integer\n  from: 0\n  to: 24\n  chunk_size: 10\n  bounds: inclusive\n",
3726            SCOPED_SOURCE,
3727        )))
3728        .unwrap();
3729        let ids: std::collections::BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
3730        assert_eq!(ids.len(), nodes.len(), "ids must be unique");
3731        assert!(nodes.iter().all(|n| n.id.contains("::partition::")));
3732    }
3733
3734    #[test]
3735    fn an_unpartitioned_config_is_completely_unchanged() {
3736        let nodes = expand(&cfg(&doc(
3737            "",
3738            "\n    type: csv\n    config:\n      path: ./in.csv",
3739        )))
3740        .unwrap();
3741        assert_eq!(nodes.len(), 1);
3742        assert!(!nodes[0].id.contains("partition"));
3743    }
3744
3745    #[test]
3746    fn a_partition_block_whose_source_ignores_the_tokens_is_rejected() {
3747        // Otherwise every chunk runs the identical query N times.
3748        let err = expand(&cfg(&doc(
3749            "partition:\n  kind: integer\n  from: 0\n  to: 9\n  chunk_size: 5\n  bounds: inclusive\n",
3750            "\n    type: csv\n    config:\n      path: ./in.csv",
3751        )))
3752        .expect_err("must be rejected");
3753        let msg = err.to_string();
3754        assert!(msg.contains("no `${partition.*}` token"), "{msg}");
3755        assert!(msg.contains("start"), "should list available tokens: {msg}");
3756    }
3757
3758    #[test]
3759    fn a_wrong_kind_token_is_rejected_naming_the_real_tokens() {
3760        let err = expand(&cfg(&doc(
3761            "partition:\n  kind: offset\n  total: 20\n  chunk_size: 10\n",
3762            SCOPED_SOURCE,
3763        )))
3764        .expect_err("id-range tokens are not offset tokens");
3765        let msg = err.to_string();
3766        assert!(msg.contains("start"), "{msg}");
3767        assert!(msg.contains("offset"), "{msg}");
3768    }
3769
3770    #[test]
3771    fn a_partitioned_row_cannot_be_a_parent_or_a_dependency() {
3772        // Its id gains a chunk suffix, so a dependent would resolve to nothing.
3773        for edge in ["parent: a\n    parent_key: id", "depends_on: [a]"] {
3774            let yaml = format!(
3775                "version: 1\nname: p\npipeline:\n  source:\n    type: csv\n    config:\n      path: ./in.csv\n  sink:\n    type: jsonl\n    config:\n      path: ./out.jsonl\nmatrix:\n  - id: a\n    partition:\n      kind: integer\n      from: 0\n      to: 9\n      chunk_size: 5\n      bounds: inclusive\n    source:\n      config:\n        path: \"./in-${{partition.start}}.csv\"\n  - id: b\n    {edge}\n"
3776            );
3777            let err = expand(&cfg(&yaml)).expect_err("must be rejected");
3778            assert!(
3779                err.to_string()
3780                    .contains("partitioned row cannot be referenced"),
3781                "{err}"
3782            );
3783        }
3784    }
3785
3786    #[test]
3787    fn the_top_level_block_applies_to_root_rows() {
3788        let nodes = expand(&cfg(&doc(
3789            "partition:\n  kind: offset\n  total: 25\n  chunk_size: 10\n",
3790            "\n    type: rest\n    config:\n      base_url: \"https://x\"\n      path: \"/r?offset=${partition.offset}&limit=${partition.limit}\"",
3791        )))
3792        .unwrap();
3793        assert_eq!(nodes.len(), 3);
3794        let p = nodes[2].source.config["path"].as_str().unwrap();
3795        assert!(p.contains("offset=20&limit=5"), "{p}");
3796    }
3797
3798    #[test]
3799    fn a_row_level_block_overrides_the_top_level_default() {
3800        let yaml = format!(
3801            "version: 1\nname: p\npipeline:\n  source:{SCOPED_SOURCE}\n  sink:\n    type: jsonl\n    config:\n      path: ./out.jsonl\npartition:\n  kind: integer\n  from: 0\n  to: 99\n  chunk_size: 10\n  bounds: inclusive\nmatrix:\n  - id: a\n    partition:\n      kind: integer\n      from: 0\n      to: 4\n      chunk_size: 5\n      bounds: inclusive\n"
3802        );
3803        let nodes = expand(&cfg(&yaml)).unwrap();
3804        assert_eq!(
3805            nodes.len(),
3806            1,
3807            "the row's own 5-wide range wins over 100/10"
3808        );
3809    }
3810}