Pipeline Contract Specification
Status: v1.24 (revised — see Change Log) Owner: datapipelines.co core Depends on: Type System spec Last updated: 2026-09-17
1. Purpose
This spec defines the structure and lifecycle of a Pipeline — the central artifact of datapipelines.co. A Pipeline is a versioned, declarative DAG of nodes that:
- Declares an input contract (parameters)
- References templates (versioned SQL generators) for each node
- References datasources by name (resolved per-environment)
- Declares where each node's output goes (tempdb staging, external datasource write-back, or caller return)
- Declares the dependency graph (which nodes wait for which)
- Declares execution settings (tempdb engine, etc.)
- Is fully portable across environments (no env-specific values embedded)
A Pipeline is authored by a human via the UI editor, or generated by an LLM via MCP, or imported from a JSON file. Once authored, it is immutable per version — editing creates a new version.
2. Design Principles
- Environment-portable by construction. No env-specific values (hostnames, credentials, IDs, UUIDs) in the Pipeline JSON. Datasources are referenced by name and resolved per-environment. Node IDs and table names are stable, human-readable strings.
- Declarative, not imperative. The Pipeline declares what runs and what depends on what, not how it runs. The executor decides parallelism, ordering, and lifecycle based on DAG topology.
- Templates are first-class, separately versioned. SQL/FTL lives in template entities, not inline in the Pipeline. Pipelines reference templates by
{id, version}. - Single shared Context at runtime. All nodes render their templates against the same Context map, initialized from the pipeline's input parameters. Future "calculators" extend the Context before/around node execution.
- Node
typedrives executor behavior.DQL,DML,DDLdeclare what kind of SQL the template generates. The executor branches: stage ResultSet (DQL), record row count (DML), record success (DDL). - Output target is explicit per node — with one default. A DQL node's output can go to tempdb (staging), an external datasource (write-back), or the caller (result return). If the
outputblock is omitted, the target iscaller. - Stable, human-readable identifiers. Node IDs, table names, datasource references — all readable strings (
fetch_orders,stg_orders,pg-prod). UUIDs are forbidden in the Pipeline JSON body. - Validate-on-write, universally. Nothing invalid ever reaches the database — pipelines, templates, datasources, every saved contract validates fully at create/update time. The executor never receives an invalid pipeline. (This principle is cross-cutting: Templates §7, Datasources §9 apply it to their own entities.)
- The caller node is the result node — declared, not topology-derived. At most one node per pipeline resolves to
output.target: "caller"(explicitly or by omission); that node's ResultSet is the pipeline's result. Zero caller nodes is legal: a pure write-back/ETL pipeline returns only execution stats. There is noterminal_node_idfield and no topology-based auto-detection.
3. Top-Level Pipeline Schema
3.1 JSON structure
{
"schema_version": 1,
"id": "a1b2c3d4-...",
"name": "acme/finance/monthly_revenue_report",
"display_name": "Monthly Revenue Report",
"description": "Joins PG orders with MySQL customers; aggregates by customer.",
"version": 3,
"owner": "user-uuid",
"created_at": "2026-08-01T10:00:00Z",
"updated_at": "2026-08-05T14:30:00Z",
"settings": {
"tempdb": {
"engine": "H2",
"config": {
"max_memory_mb": 1024
}
}
},
"parameters": {
"start_date": {
"type": "DATE",
"required": true,
"description": "Inclusive start of the reporting period."
},
"end_date": {
"type": "DATE",
"required": true,
"description": "Inclusive end of the reporting period."
},
"min_total": {
"type": "BIGDECIMAL",
"precision": 12,
"scale": 2,
"required": false,
"default": "0.00",
"description": "Filter customers with lifetime value below this."
},
"include_cancelled": {
"type": "BOOLEAN",
"required": false,
"default": false,
"description": "Whether to include cancelled orders."
}
},
"nodes": [
{
"id": "fetch_orders",
"description": "Pull orders in date range from PG.",
"type": "DQL",
"source": "pg-prod",
"template": {"id": "acme/finance/fetch_orders.sql", "version": 2},
"output": {"target": "tempdb", "table": "stg_orders"},
"depends_on": []
},
{
"id": "fetch_customers",
"description": "Pull active customers from MySQL.",
"type": "DQL",
"source": "mysql-prod",
"template": {"id": "acme/finance/fetch_customers.sql", "version": 1},
"output": {"target": "tempdb", "table": "stg_customers"},
"depends_on": []
},
{
"id": "revenue_by_customer",
"description": "Join orders + customers; aggregate revenue.",
"type": "DQL",
"source": "tempdb",
"template": {"id": "acme/finance/join_revenue.sql", "version": 1},
"output": {"target": "tempdb", "table": "int_revenue"},
"depends_on": ["fetch_orders", "fetch_customers"]
},
{
"id": "cache_to_warehouse",
"description": "Materialize the revenue table to the reporting warehouse.",
"type": "DQL",
"source": "tempdb",
"template": {"id": "acme/finance/select_revenue.sql", "version": 1},
"output": {
"target": "datasource",
"datasource": "pg-warehouse",
"table": "monthly_revenue_cache",
"mode": "replace"
},
"depends_on": ["revenue_by_customer"]
},
{
"id": "final_report",
"description": "Final projection and filter for caller.",
"type": "DQL",
"source": "tempdb",
"template": {"id": "acme/finance/final_report.sql", "version": 1},
"output": {"target": "caller"},
"depends_on": ["revenue_by_customer"]
}
]
}
3.2 Field reference
| Field | Type | Required | Description |
|---|---|---|---|
schema_version |
integer | yes | Pipeline schema version. Currently 1. |
id |
string (UUID) | yes | Pipeline identifier. Stable across versions. |
name |
string | yes | Machine name, and a folder path: 2–10 /-separated segments, each [a-z0-9][a-z0-9_.-]{0,63}, ≤ 200 chars total — the same grammar template names take (Template Hierarchy §4.1, §14). A folder is required: test/scratch is a name, scratch is refused. The full path IS the name; folders are virtual and have no identity. Stable identifier for MCP references and cross-pipeline calls; there is no rename (Template Hierarchy §4.5). |
display_name |
string | yes | Human-readable name. Shown in UI. |
description |
string | yes | Long-form description. Shown in UI and MCP tool descriptions. |
version |
integer | yes | Pipeline version. Monotonically increasing per pipeline. |
owner |
string (UUID) | yes | User ID of the pipeline owner. |
created_at |
ISO 8601 timestamp | yes | Creation time. |
updated_at |
ISO 8601 timestamp | yes | Last modification time. |
settings |
object | optional | Pipeline-level execution settings. See §5. |
parameters |
object | yes | Input parameter declarations. See §6. |
nodes |
array of Node | yes | The DAG node list. See §4. Must be non-empty. |
checks |
array of Check | optional | The release checks — cross-checks the SERVER runs before a release is allowed. See §3.3. Omitted means none; "checks": [] canonicalizes to omitted. |
Composed read shape vs portable body (2026-08-08). The JSON above is the shape a client READS. The portable pipeline body — what authors submit, what
pipeline_versions.body_jsonstores, what exports carry — is the author-owned fields only:schema_version,name,display_name,description,settings,parameters,nodes, and the optionalchecks(§3.3, added 2026-09-14 — additive per §15.2). The server-assigned fields (id,version,owner,created_at,updated_at) live on the pipeline record and are composed into read responses; on input they are ignored entirely (absent from the inbound model — the strongest form of the write-protection rule; they cannot be smuggled in any casing). |
3.3 Release checks (checks[])
A pipeline body MAY carry checks: an optional list of read-only statements the server runs against a datasource the workspace can read, each paired with the value the author expects it to produce. Release of the version is gated on every check passing — the run outcomes and the refusal code are §13.17; the save-time rules are §12.12.
"checks": [
{
"id": "row_count",
"name": "Orders row count matches the raw rollup.",
"datasource": "pg-prod",
"sql": "SELECT COUNT(*) FROM orders WHERE created_at >= :start_date",
"expected": { "kind": "value", "value": 1023412, "tolerance": 0 }
},
{
"id": "null_share",
"name": "Null customer ids stay below one percent.",
"datasource": "pg-warehouse",
"sql": "SELECT COUNT(*) FILTER (WHERE customer_id IS NULL)::float / COUNT(*) FROM orders",
"expected": { "kind": "range", "min": 0, "max": 0.01 }
},
{
"id": "calendar_complete",
"name": "No gap rows in the calendar spine.",
"datasource": "pg-warehouse",
"sql": "SELECT d FROM calendar d WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.day = d)",
"expected": { "kind": "rows", "rows": 0 }
}
]
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Identifier, [a-z0-9_]{1,63}, unique within the body; the run rows key on it. |
name |
string | yes | The human sentence a release dialog shows, 1–200 characters. |
datasource |
string | yes | A registered datasource name, resolved per-environment like a node source. tempdb is refused: the staging database exists only inside an execution, and a check runs with no execution context. |
sql |
string | yes | One read-only statement. A check has no rendering: ${} interpolation is refused; values bind as :name SQL parameters only. |
expected |
object | yes | { "kind": "value" | "range" | "rows", ... }. value needs value (numeric compare, absolute tolerance, default 0); range needs min ≤ max (inclusive); rows needs a non-negative rows. |
Normative notes:
checksis versioned with the body exactly likenodes: additive per §15.2, and body-hash neutral — an existing body (nocheckskey) deserializes to an empty list and serializes back byte-identically; an explicit"checks": []canonicalizes to the same absent form.- Every
:namebind insqlmust name a declared pipeline parameter (§6). The calculator Context (§7.2) is NOT available to a check — a check runs outside any execution. A run binds the declared parameters exactly as an execute does, and the release gate's run supplies no parameters: for any parameter a check binds, the declared DEFAULTS are what the gate proves — not every combination a caller can pass — while a check written against fixed literals proves exactly the baseline those literals name. Expectations are static — a fixed value, range or row count — so an expectation that is only true for one window is a baseline-specific statement, and the baseline belongs in the check'sname. - The author — agent or human — supplies the query and the expectation, never an observed value.
observedexists only on the server's own run rows (pipeline_check_runs, metadata-db §4.20); the body's shape has no field that could carry one in.
4. Node Schema
4.1 JSON structure (DQL node, staging to tempdb)
{
"id": "fetch_orders",
"description": "Pull orders in date range from PG.",
"type": "DQL",
"source": "pg-prod",
"template": {"id": "acme/finance/fetch_orders.sql", "version": 2},
"output": {"target": "tempdb", "table": "stg_orders"},
"depends_on": []
}
4.2 JSON structure (DQL caller node, returns to caller)
{
"id": "final_report",
"description": "Final projection.",
"type": "DQL",
"source": "tempdb",
"template": {"id": "acme/finance/final_report.sql", "version": 1},
"output": {"target": "caller"},
"depends_on": ["revenue_by_customer"]
}
4.3 JSON structure (DQL node, write-back to external datasource)
{
"id": "cache_to_warehouse",
"description": "Materialize revenue to reporting warehouse.",
"type": "DQL",
"source": "tempdb",
"template": {"id": "acme/finance/select_revenue.sql", "version": 1},
"output": {
"target": "datasource",
"datasource": "pg-warehouse",
"table": "monthly_revenue_cache",
"mode": "replace"
},
"depends_on": ["revenue_by_customer"]
}
4.4 JSON structure (DML node, side-effect only)
{
"id": "update_last_run",
"description": "Update last_run timestamp in metadata.",
"type": "DML",
"source": "pg-meta",
"template": {"id": "acme/finance/update_last_run.sql", "version": 1},
"depends_on": ["final_report"]
}
Note: no output block. DML's side effect IS the output.
4.5 JSON structure (DDL node, schema setup)
{
"id": "create_index",
"description": "Create index on staging table for join performance.",
"type": "DDL",
"source": "tempdb",
"template": {"id": "acme/finance/create_idx_revenue.sql", "version": 1},
"depends_on": ["revenue_by_customer"]
}
4.6 Field reference
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Node identifier. [a-z0-9_]+. Unique within the pipeline. Stable across versions and environments. |
description |
string | yes | Human-readable. Shown in UI editor. |
type |
string (enum) | yes | One of DQL, DML, DDL, PIPELINE, CALCULATOR. Drives executor behavior. |
source |
string | yes, except PIPELINE and CALCULATOR | Datasource name, OR "tempdb" for in-memory staging. Must be a registered datasource name in the env, or "tempdb". Forbidden on PIPELINE nodes (§12.9) and CALCULATOR nodes (§12.10). |
template |
object | yes, except PIPELINE and CALCULATOR | Template reference: {id, version}. Immutable. See Templates spec. Forbidden on PIPELINE nodes (§12.9) and CALCULATOR nodes (§12.10). |
pipeline |
object | PIPELINE nodes only | Child pipeline reference: {name, version} — the pinned pipeline version the node executes. Required on PIPELINE nodes; absent on SQL node types. See §4.9. |
parameters |
object | no | Child input bindings on a PIPELINE node: each key names a child declared parameter or a child calculator context_key; each value is a typed literal in the target's §6.3 wire encoding, or "${ref}" resolving against the parent's Context tiers (a parent parameter, a parent calculator context_key, an org/platform key) at the identical type. See §4.9. |
kind |
string | CALCULATOR nodes only | The catalog calculator this node evaluates (Calculators §2). Required on CALCULATOR nodes; forbidden on every other type. See §4.10. |
inputs |
object | CALCULATOR nodes only | The kind's inputs, by input name. A "$name" string is a reference to a Context key; every other JSON value is a literal typed against the kind's declared input type. Required on CALCULATOR nodes; forbidden on every other type. See §4.10. |
context_key |
string | CALCULATOR nodes on a single-output kind | The Context key this node writes, per §6.1's [a-z_][a-z0-9_]*. Deliberately not called output: it names a value downstream nodes bind as :context_key, never a table. XOR with context_keys; forbidden on every other type. See §4.10. |
context_keys |
object | CALCULATOR nodes on a multi-output kind | The Context key each of the kind's named outputs is written to, as {output name: context key} — every declared output mapped, each key per §6.1. XOR with context_key; forbidden on every other type. See §4.10. |
output |
object | conditional | Optional for DQL nodes — omitted means {"target": "caller"}. Forbidden for DML / DDL / CALCULATOR nodes. On PIPELINE nodes, permitted only when the pinned child has a caller node (§12.9). See §4.7. |
depends_on |
array of string | yes | Parent node IDs. Empty array for source nodes. Must reference existing node IDs. No cycles. Data flow only — never an edge added to avoid contention between nodes; the executor owns scheduling (§4.11). |
settings |
object | no | Per-node execution settings. v1 holds one key, timeout_seconds — this node's wall-clock deadline. See §4.11. |
4.7 output block reference
The output block declares where the node's ResultSet goes. Optional for DQL nodes — omitted means {"target": "caller"}; forbidden for DML/DDL. On a PIPELINE node the block declares where the child execution's caller result lands, and is permitted only when the pinned child has a caller node (§12.9 pipeline_output_on_sideeffect_child).
target value |
Additional fields | Description |
|---|---|---|
"tempdb" |
table: string |
Stage the ResultSet into the in-memory tempdb under the given table name. Downstream nodes can query it via source: "tempdb" SQL referencing the table. A node whose data downstream nodes consume must declare this explicitly. |
"caller" |
(none) | Return the ResultSet as the pipeline's result. Default when output is omitted. At most one node per pipeline may resolve to this target; zero is legal (write-back pipelines return stats only). |
"datasource" |
datasource: string, table: string, mode: "replace" | "append" |
Stream the ResultSet to an external datasource's table. replace = TRUNCATE+INSERT in one transaction; append = INSERT only. The target table must already exist in the datasource (or be created by a preceding DDL node in the pipeline). |
4.8 source field rules
sourceis a string. Either:- A registered datasource name in this environment (validated against the datasource registry at write time), OR
- The reserved literal
"tempdb", meaning "run this SQL against the in-memory staging database for this execution."
- The reserved
"tempdb"literal cannot also be a registered datasource name (validation rejects registering a datasource with that name).
4.9 JSON structure (PIPELINE node)
{
"id": "revenue",
"description": "Monthly revenue component.",
"type": "PIPELINE",
"pipeline": {"name": "acme/finance/monthly_revenue", "version": 4},
"parameters": {"start_date": "${start_date}", "region": "EU"},
"output": {"target": "tempdb", "table": "stg_revenue"},
"depends_on": []
}
A PIPELINE node executes another pipeline — the version pinned by pipeline — as a real, separate child execution (§8.5) and consumes its result. Composition is by invocation, not inlining: the child keeps its own execution record, tempdb, stats, and SSE stream.
Field rules:
pipeline— required:{name, version}.nameper §3.2's path grammar (a child may live under any folder);versiona positive integer pinning an existing, immutable pipeline version. Self-reference (name= the containing pipeline's own name) is invalid. There is no "latest".sourceandtemplate— forbidden (mirrors "output forbidden on DML/DDL"): the node runs a pipeline, not SQL.parameters— optional map filling the child's inputs: each key names a child declared parameter or one of the pinned child's CALCULATORcontext_keys (078 A5-composition — supplying the key skips the child's node exactly as a direct execute-time supply does, §4.10; child calculator keys are optional, never required, sopipeline_parameter_unmappedstill reads declared parameters only). Each value is either a typed literal obeying the target's §6.3 wire encoding, or the string form"${ref}"resolving against the PARENT's Context tiers: a parent declared parameter, a parent CALCULATORcontext_key(typed by its kind's output), or an org/platform key (org always STRING, platform per §0.2's canonical types) — at the identical type. An ANY-output key on either side skips the type check: the value is typed only by the run. No expressions, no concatenation — a value is a literal or a reference, nothing in between (v1). No auto-passthrough: a parent and child calculator key spelled the same are NOT implicitly mapped — the mapping is always an explicit entry here, or the child's node computes its own value.output— standard §4.7 block, permitted only when the pinned child has a caller node. Zero-caller child ⇒outputmust be absent; the node is side-effect-only and downstreamdepends_ongives ordering.depends_on— unchanged.
A pipeline whose entity is DISCARDED (every version discarded) still resolves existing pinned references — the pinned version keeps resolving — but blocks NEW references at save time (pipeline_reference_deleted, §12.9). This mirrors template deletion exactly. A PIPELINE node may pin only a RELEASED child version (pipeline_reference_not_released, 101/D58).
4.10 JSON structure (CALCULATOR node)
{
"id": "fiscal_q",
"description": "The fiscal quarter this run reports on.",
"type": "CALCULATOR",
"kind": "fiscal_quarter",
"inputs": {"date": "$current_date", "fiscal_start": "$org_fiscal_start_date"},
"context_key": "run_fiscal_quarter",
"depends_on": []
}
A CALCULATOR node evaluates one pure catalog function (Calculators) and writes typed values into the execution Context. It runs no SQL, touches no database, and produces no table. Downstream nodes read a value the way they read any Context key — :run_fiscal_quarter in a template, "$run_fiscal_quarter" in another calculator's inputs.
The node writes one value or a named set, decided by the kind, never by the author. A single-output kind (most of the catalog) writes its one value under context_key, as above. A multi-output kind declares a set of named outputs (period_bounds declares start and end), and the node maps EVERY output to a Context key through context_keys:
{
"id": "window",
"description": "The quarter this run reports on.",
"type": "CALCULATOR",
"kind": "period_bounds",
"inputs": {"date": "$current_date", "unit": "quarter"},
"context_keys": {"start": "window_start", "end": "window_end"},
"depends_on": []
}
Field rules:
kind— required: a name in the registry. The catalog is additive and akindnever changes meaning, because akindis written into bodies that are versioned, exported and promoted. The catalog entry says whether the kind is single- or multi-output.inputs— required (an empty object is legal for a kind with only optional inputs)."$name"is a reference to a Context key; anything else is a literal typed against the kind's declared input type, which is what makes"fiscal_start": "09-15"a per-pipeline override with no config edit. An input the kind declares optional may be omitted, and the kind then applies its documented default.context_key— required on a single-output kind, per §6.1. It may shadow an org or platform key; it may never shadow a declared parameter (§12.10calculator_output_collision), and no two nodes may write the same key.context_keys— required on a multi-output kind: an object mapping every declared output name to its Context key ({"start": "window_start", "end": "window_end"}). No partial mapping — a caller who needs one value still maps both, so no reader can bind a key the node never writes. Each mapped key obeys every rulecontext_keydoes: §6.1's name shape, no shadowing a declared parameter, one writer per key.context_keyXORcontext_keys— never both, never neither, and the field must fit the kind's shape (context_keyon a multi-output kind orcontext_keyson a single-output one is refused, §12.10calculator_output_shape_mismatch).source,template,output— forbidden, for the same reasonsource/templateare forbidden on a PIPELINE node: this node is not the kind of thing they describe.depends_on— unchanged, and load-bearing in a way it is not elsewhere: sequencing is topology. A reference to another node's key, and a SQL node binding:that_key, are valid only from a node that depends on the producer, directly or transitively (§12.10calculator_input_unordered). Binding only ONE of a multi node's keys still requires the edge. Array order means nothing.
At run time the node evaluates at its DAG position, writes its value (once, every key on a multi-output kind), and reports through SSE and history like any other node — rows_out: 0, plus context_key and context_value on a single node's stats (context_values, every key, on a multi node's) so the run detail page and executions_get show what it produced. A failure is the standard node failure record with pipeline.node.calculator_failed (§13.4).
Every key a calculator node writes is also an implicit optional execute input (078, owner ruling 2026-09-05; extended to the named set 121). A caller may supply it in the execute request's parameters object, typed by the kind's output — an ANY-output kind (coalesce, if_null, map) accepts any JSON scalar. Supplied, the node is skipped: it does not evaluate, the supplied value is what downstream nodes bind, and the node's stats carry provided_by: "caller" so a run record shows where the value came from. Unsupplied (an explicit JSON null reads as unsupplied), the node runs and computes the value exactly as before. A supplied value that fails coercion is refused with pipeline.execution.invalid_parameter_type (§13.3), exactly like a declared parameter — to the caller there is no second kind of execute input. For a multi-output node the override is all-or-nothing: every key supplied and the node is skipped, none and it computes; a proper subset is refused before any node runs with pipeline.execution.calculator_keys_partial (§13.3).
4.11 settings.timeout_seconds / settings.query_timeout_seconds — a node's own deadline and statement budget
{
"id": "scan_trips",
"type": "DQL",
"source": "lake",
"template": { "id": "trips/scan", "version": 3 },
"output": { "target": "tempdb", "table": "trips" },
"depends_on": [],
"settings": { "timeout_seconds": 600, "query_timeout_seconds": 300 }
}
| Field | Type | Required | Description |
|---|---|---|---|
timeout_seconds |
integer | no | This node's WALL-CLOCK deadline, in seconds, overriding datapipelines.executor.node-timeout-seconds (default 300) for this node alone. Must be a positive integer no greater than datapipelines.executor.node-timeout-max-seconds (default 900), or the save is refused with pipeline.validation.node_timeout_invalid (§12.8). |
query_timeout_seconds |
integer | no | This node's own SQL statement timeout (156, #2), in seconds — overrides the pipeline's settings.query_timeout_seconds (§5.3), the datasource's query_timeout_seconds and the operator's per-dialect/application default, for this node alone. Legal only on a node type that runs a statement (DQL, DML, DDL); declared on PIPELINE or CALCULATOR it is refused. Must be a positive integer no greater than datapipelines.executor.node-query-timeout-max-seconds (default 900) and no greater than this node's own effective timeout_seconds (above) — refused, not clamped, with pipeline.validation.node_query_timeout_invalid (§12.8) naming both numbers. |
Three budgets, one precedence (the same table appears in configuration.md §3.2 and dag-executor.md §5.3):
| Bound | Setting | Scope | Enforced by |
|---|---|---|---|
| Execution | datapipelines.executor.execution-timeout-seconds (600) |
the whole execution | the executor |
| Node | node.settings.timeout_seconds, else datapipelines.executor.node-timeout-seconds (300) |
one node: RENDER → CONNECT → EXECUTE → STAGE → MATERIALIZE | the executor |
| Statement | node.settings.query_timeout_seconds (156, §5.3), else pipeline settings.query_timeout_seconds, else the datasource's query_timeout_seconds, else the dialect's operator default (configuration.md §3.2), else datapipelines.executor.node-query-timeout-seconds (60) |
one execute* call |
the JDBC driver |
Read it downward: the execution deadline is the outermost, the statement timeout the innermost, and the node deadline is what closes the gap between them. The statement bound is the driver's, and drivers honour it unevenly — a node whose driver ignores it is stopped by the node deadline anyway, which is exactly why the middle row exists. A pipeline-level settings.execution.timeout_seconds is still §5.4 future work: the outermost bound is the operator's setting, not the pipeline's.
Statement-timeout precedence in full (156, #2). The statement row above compresses five tiers into one cell; read individually: a node may override the pipeline's statement timeout, which may override the datasource's, which may override the operator's per-dialect default, which falls back to the flat application default. settings.query_timeout_seconds — at node level or pipeline level (§5.3) — is bounded by the SAME operator ceiling as node.settings.timeout_seconds: datapipelines.executor.node-query-timeout-max-seconds (default 900), refused rather than clamped at save (pipeline.validation.node_query_timeout_invalid / pipeline.validation.pipeline_query_timeout_invalid, §12.8). A node's own query_timeout_seconds must also not exceed that SAME node's effective wall-clock deadline (the Node row above) — a statement budget longer than the node's own lifecycle could ever reach is refused at save, naming both numbers. pipeline.node.query_timeout's failure detail (§13.4) names which tier resolved the effective value (source: node | pipeline | datasource | dialect | application), so an author can tell whether their own override took effect or an operator default did.
A node that exceeds its deadline fails with pipeline.node.timeout (§13.4, HTTP 504), whose details carry timeout_seconds, elapsed_ms and the phase the budget went in.
A timeout is a signal, not a wall to route around. The first response to a node that will not fit its budget is to make the work smaller — pre-aggregate, push the filter down, prune on the partition column. Slicing one scan into four quarterly nodes to dodge the deadline produces a pipeline that is slower, four times as likely to fail, and lies about what it does. When the scan is legitimately long, raise THIS node's timeout_seconds and say why.
A PIPELINE node that declares no timeout_seconds is not bounded by the node deadline: its work is a child execution, already bounded by execution-timeout-seconds one level down. One that declares it, is.
5. Settings
5.1 settings.tempdb — staging engine configuration
"settings": {
"tempdb": {
"engine": "H2",
"config": {
"max_memory_mb": 1024
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
engine |
string | optional (default "H2") |
Staging engine. v1 supports only "H2". Future: "DUCKDB", etc. |
config |
object | optional | Engine-specific configuration. Keys depend on engine. For H2: max_memory_mb (default 1024). |
If settings.tempdb is omitted entirely, defaults to H2 with default config.
5.2 Why pipeline-level (not global)
- Different pipelines have different needs: a small lookup-heavy pipeline fits in 256 MB; a large aggregation pipeline needs 4 GB.
- Pipeline authors declare the resource need upfront; operators budget accordingly.
- Settings travel with the pipeline across environments — same engine choice in dev and prod (the actual
pg-prodconnection differs, but the staging engine choice doesn't).
5.3 settings.query_timeout_seconds — the pipeline-wide SQL statement timeout
"settings": {
"query_timeout_seconds": 300
}
| Field | Type | Required | Description |
|---|---|---|---|
query_timeout_seconds |
integer | no | The default SQL statement timeout, in seconds, for every DQL/DML/DDL node in this pipeline that does not declare its own node.settings.query_timeout_seconds (§4.11). Overrides the datasource's query_timeout_seconds and the operator's per-dialect/application default; overridden itself by a node's own setting. Must be a positive integer no greater than datapipelines.executor.node-query-timeout-max-seconds (default 900), or the save is refused with pipeline.validation.pipeline_query_timeout_invalid (§12.8). |
Pipeline-level, not global, for the same reason §5.2 gives settings.tempdb: different pipelines scan different volumes against different engines, authors declare the resource need once for the whole pipeline instead of repeating it on every node, and the setting travels with the pipeline across environments. It does not raise or lower any node's own wall-clock deadline (node.settings.timeout_seconds) — the two settings are independent, and §12.8's invariant is checked per node against whichever statement timeout applies to it.
5.4 Future settings (out of scope for v1)
settings.execution.parallelism— per-pipeline concurrency limit override (different from the global default).settings.execution.timeout_seconds— per-pipeline overall timeout.settings.templates.cache_size— override template cache size for this pipeline's executions.settings.output.default_format— default wire format for/execute(JSON / Arrow / CSV).
6. Parameters (Input Map Declaration)
6.1 Schema
parameters is an object whose keys are parameter names ([a-z_][a-z0-9_]*, length 1–63 — no leading digit, since a name Freemarker cannot reference is undeclarable; the 63-char cap matches the identifier caps elsewhere and stops an unbounded key becoming an unbounded error string) and whose values are parameter descriptors.
"parameters": {
"param_name": {
"type": "INTEGER" | "BIGINTEGER" | "DECIMAL" | "BIGDECIMAL" |
"BOOLEAN" | "STRING" | "BINARY" | "DATE" | "TIME" | "TIMESTAMP",
"required": true | false,
"default": <value>, // optional; only honored if required = false
"precision": <int>, // required for DECIMAL, BIGDECIMAL
"scale": <int>, // required for BIGDECIMAL; required for DECIMAL with exact-numeric semantics
"description": "..." // optional but recommended
}
}
6.2 Rules
typeis one of the canonical types from Type System §3, excludingNULL.required: trueanddefaultpresent is invalid (validation error:pipeline.validation.conflicting_required_default).precision/scalerules match the Type System §7.3 column descriptor rules.defaultmust be valid JSON matching the wire encoding for the type:INTEGER,DECIMAL→ JSON numberBIGINTEGER,BIGDECIMAL,STRING,BINARY,DATE,TIME,TIMESTAMP→ JSON stringBOOLEAN→ JSON boolean
- Parameter values supplied at execution time must satisfy the schema. Type mismatch →
pipeline.execution.invalid_parameter_type.
6.3 Wire encoding of input parameter values
Input parameter values are submitted by clients (via REST API or MCP tool) as JSON. Their wire encoding follows the same rules as Type System §3.1 — BIGINTEGER and BIGDECIMAL parameters are sent as strings; INTEGER and DECIMAL (precision ≤ 15) as numbers; etc.
This is the symmetric contract: data flows in and out of the pipeline using the same type rules.
Coercion is strict — wrong wire encoding is rejected, never silently converted:
- A JSON number supplied where
BIGDECIMAL/BIGINTEGER(string-on-wire) is declared →pipeline.execution.invalid_parameter_type. (Accepting it would silently lose precision for values beyond IEEE 754 safe range.) - A JSON string supplied where
INTEGER/DECIMAL/BOOLEAN(number/boolean-on-wire) is declared →pipeline.execution.invalid_parameter_type. TIMESTAMPparameter values MUST carry an explicit offset orZ; a zone-less timestamp string is rejected — the server never guesses the client's timezone.DATE/TIMEvalues must be exact ISO 8601 (YYYY-MM-DD,HH:MM:SS[.ffffff]). The fractional part, when present, is 1–6 digits — sub-microsecond input is rejected, not silently truncated (2026-08-08: strictness applies on ingress exactly as on egress; leniency here would make §3.5's exact egress a silent transformation).BINARYparameter values must be PADDED standard base64 (RFC 4648 §4, length ≡ 0 mod 4) — the same alphabet and padding §3.5 mandates on egress; unpadded input is rejected (2026-08-08).
7. Execution Context (Runtime Construct)
The Context is a runtime in-memory map — never serialized in the Pipeline JSON. It is constructed per execution.
7.1 Lifecycle
- Pipeline execution starts. The executor seeds the Context with the org tier — the
deployment's
datapipelines.org.*values (Configuration §3.21) — and then the platform tier:current_date,current_timestamp,execution_id. - Executor reads pipeline input parameters (from REST/MCP call).
- Executor validates each parameter against the pipeline's
parametersschema. Defaults applied for missing optional params. - Executor overlays the resolved parameters onto the Context:
Map<String, Any?>where keys are context keys and values are typed Kotlin objects (Date, BigDecimal, Boolean, String, etc.). A declared parameter that spells an org or platform key the same way is the override. - For each node, in topological order:
a. The template engine renders the node's template against the current Context, and the rendered SQL's
:keybinds resolve against it too. b. ACALCULATORnode (§4.10) evaluates its kind once and writes every key it declares — itscontext_key, or each mappedcontext_keysvalue — into the Context; every node thatdepends_onit, directly or transitively, sees them. c. The rendered SQL executes against the node'ssource. d. Behavior depends ontypeandoutput.target(see §8). - The caller node's ResultSet (the node resolving to
output.target: "caller", if any) is the pipeline's result. Pipelines with no caller node return execution stats only. - The fully resolved Context — every tier, calculator outputs included — is persisted with the execution (DAG Executor §8).
7.2 What's in the Context — one namespace, five tiers
Every value a node can bind is a typed Context key matching §6.1's [a-z_][a-z0-9_]*. A node
binds :key without knowing which tier supplied it; the tier only decides who wins when two of
them spell a key the same way. Lowest precedence first:
| # | Tier | Keys | Who sets it |
|---|---|---|---|
| 1 | org config | org_currency_name, org_currency_symbol, org_fiscal_start_date, org_week_start, org_timezone — the yml path minus the datapipelines.org. prefix, dots and dashes as _. All typed STRING; org_fiscal_start_date is an MM-DD string the calculator kinds parse |
the deployment's application.yml (Configuration §3.21) |
| 2 | platform | current_date (DATE, evaluated in org_timezone), current_timestamp (TIMESTAMP), execution_id (STRING) |
the executor, at execution start |
| 3 | declared parameters |
whatever §6.2 declares, after defaulting | the pipeline body — declaring a key an org or platform value also provides IS the override, and it is visible in the body |
| 4 | execute-time inputs | declared parameters (§6.3), and every key a CALCULATOR node writes as an implicit optional input (§4.10 — supplied → the node is skipped; unsupplied → the node runs; a multi-output node's keys are all-or-nothing) |
the caller's parameters object |
| 5 | calculator outputs | every key a CALCULATOR node writes (§4.10 — one, or a multi-output kind's whole mapped set) |
the node, at its DAG position |
A calculator output may shadow an org or platform key; it may never shadow a declared
parameter, and one is refused at save time with pipeline.validation.calculator_output_collision
(§12.10). A calculator that RUNS writes over everything below tier 5 — including a caller-supplied
value for a different key — but a key the caller supplied skips its node (§4.10), so the two
never contest the same key in one run. Org and platform keys are deployment constants, so the
save-time dry render knows them: a template binding :org_currency_symbol validates without the
pipeline declaring anything. None of them is a secret.
7.3 What's NOT in the Context
- Upstream node outputs (those are tempdb tables OR external-datasource tables, referenced by name in SQL).
- Connection credentials or env-specific values.
- Execution metadata beyond the platform tier's three keys — timings, node stats and the result's location travel on the execution record, not in a namespace templates render against.
7.4 Template variable resolution
Templates use Freemarker syntax to reference Context keys. A key declared in parameters is
a value and is referenced as a bind parameter — :name — which the executor binds on a
prepared statement, so a caller-supplied STRING is never parsed as SQL (Templates §4.5).
${} interpolation is for structure (table names, dynamic fragments) and is refused for
declared parameter names at save time (template.validation.parameter_interpolated, §12.6):
SELECT order_id, customer_id, total_amount
FROM orders
WHERE order_date BETWEEN :start_date AND :end_date
AND total_amount >= :min_total
The template engine enforces that all referenced variables exist in the Context — referencing an undefined variable is a render failure (pipeline.node.template_render_failed).
This is also enforced at save time: pipeline validation dry-renders every referenced template against the pipeline's declared parameters (using defaults where present, type-appropriate sample values otherwise). A template variable with no corresponding pipeline parameter fails validation with pipeline.validation.template_parameter_undeclared — the error never waits for execution. Pipeline parameters is the single declaration point for template variables; templates do not declare their own parameter schemas (Templates spec).
8. Node Execution Behavior (per type and output.target)
Consistency: a write-back node commits on its own connection when it finishes, and nothing rolls it back if a later node fails — each node is atomic on its own database, the pipeline as a whole is not. The full model, the seams between databases, and how to design a write-back around it are DAG Executor §16.
8.1 DQL nodes
A DQL node runs a SELECT query and produces a ResultSet. Behavior depends on output.target:
output.target |
Executor behavior |
|---|---|
tempdb |
Stream the ResultSet into the tempdb table named by output.table. Downstream nodes reference this table by name in their SQL. |
caller (default when output omitted) |
Materialize the ResultSet into the Redis result store and emit data_ready with the schema, the inline first page, and the result cursor (REST API §7). At most one node per pipeline resolves to this target. |
datasource |
Stream the ResultSet to the external datasource + table named in output. Apply mode: replace does TRUNCATE (or DELETE) + INSERT in one transaction; append does INSERT only. The target table must exist (created by a preceding DDL node or pre-existing). |
8.2 DML nodes
A DML node runs INSERT, UPDATE, DELETE, or MERGE against its source. The executor:
- Renders the template, executes the SQL.
- Captures the affected row count.
- Records row count in
node_stats. No staging, no output block.
DML nodes are side-effect sinks. They typically appear as the last node(s) in a pipeline that needs to write back results — but write-back is more naturally modeled as a DQL node with output.target: "datasource" (let the framework handle the INSERT). DML is for cases where the SQL is too complex for the framework's auto-streaming (conditional logic, MERGE with custom conflict handling, etc.).
8.3 DDL nodes
A DDL node runs CREATE, ALTER, DROP, or TRUNCATE against its source. The executor:
- Renders the template, executes the SQL.
- Records success/failure.
- No staging, no output block.
Use cases: create indexes on tempdb tables for join performance, create target tables in external datasources before a write-back DQL node, prepare schema state.
8.4 Type-based execution dispatch
when (node.type) {
DQL -> executeDql(node, context, staging)
DML -> executeDml(node, context)
DDL -> executeDdl(node, context)
PIPELINE -> executeSubPipeline(node, context) // §8.5 — before render/source resolution
}
fun executeDql(node, context, staging) {
val sql = render(node.template, context)
val source = resolveSource(node.source, staging)
val rs = source.executeQuery(sql)
// omitted output block resolves to Caller at deserialization time
when (val output = node.output ?: NodeOutput.Caller) {
is NodeOutput.Tempdb -> staging.stage(rs, output.table)
is NodeOutput.Caller -> materializeCallerResult(rs) // → Redis result store, REST API §7
is NodeOutput.Datasource -> streamToExternal(rs, output.datasource, output.table, output.mode)
}
}
8.5 PIPELINE nodes
A PIPELINE node executes the pipeline pinned by its pipeline reference as a child execution: a real, separate execution with its own execution record, own tempdb, own stats, and own SSE stream, started through the internal execution service (never HTTP). The child runs under the parent's principal; authorization is checked on the parent only. The {name, version} reference resolves within the active workspace — cross-workspace references do not exist in v1. Dispatch happens before render/source resolution — a PIPELINE node carries neither a template nor a source.
- Parameters. The node's
parametersmap becomes the child's execution parameters: literals pass through as supplied; each"${parent_param}"reference resolves to the parent execution's bound value for that parameter. - Result. The child's caller-node ResultSet streams directly to the parent executor (
directdelivery — nothing is materialized to the result store, and the result is not re-fetchable afterwards; re-running is the recovery path) and lands per the node'soutputblock, exactly like a DQL node's ResultSet (§8.1). A zero-caller child produces no stream: the parent waits for child completion, and success/failure is the node's outcome. - Failure. A failed child fails the PIPELINE node fail-fast with
pipeline.node.child_execution_failed; the detail carries the child's error code and execution id, so the debugging trail leads to a real execution record. - Cancellation. Cancelling an ancestor cancels the whole family: the cancellation flag is honored for every execution sharing the family's
root_execution_id(Metadata DB §4.6). A descendant stopped because an ancestor was cancelled — or because an ancestor'sexecution-timeout-secondsexpired — endsABORTED, neverFAILED: it did not fail, and its own deadline did not expire. Only the execution whose own deadline fired reportspipeline.execution.timeout. - Guards. Composition depth is bounded by
datapipelines.pipelines.max-composition-depth(default 5), checked statically at save time (composition_too_deep, §12.9 — pins are immutable, so the reference tree is fully computable) and again at run time (pipeline.node.composition_depth_exceeded— a backstop; reaching it means save-time validation was bypassed). Both count the same unit: pipelines. A pipeline with no PIPELINE nodes is depth 1 and the bound is inclusive, so the default admits a chain of 5 pipelines (4 parent→child hops) and refuses the 6th — identically at save time and at run time. Cycles are impossible by construction: a pin references an existing, immutable version. Child executions do not take per-user concurrency slots — only root executions do; a waiting parent holding a slot while its children queue would deadlock.
9. The Caller Node (Result Node)
There is no terminal_node_id field and no topology-based auto-detection. The result node is simply the node that resolves to output.target: "caller" — explicitly, or by omitting its output block (§4.7).
9.1 Resolution
- For each DQL node, resolve its effective target: the declared
output.target, orcallerifoutputis omitted. - At most one node may resolve to
caller— this is the caller node, and its ResultSet is the pipeline's result. - Zero caller nodes is legal: the pipeline is a pure write-back/ETL pipeline. Execution returns stats only and emits no
data_readyevent.
DAG position is irrelevant: the caller node may be a sink, or sit mid-DAG (though mid-DAG caller nodes are unusual — a node whose data downstream nodes consume must stage to tempdb, so a caller node's data is consumed by nobody else).
9.2 Validation rules
| Code | Check |
|---|---|
pipeline.validation.multiple_caller_nodes |
More than one node resolves to output.target: "caller" (explicitly or by omission). |
pipeline.validation.non_dql_caller_target |
A DML or DDL node has an output block (caller or otherwise). Only DQL nodes have outputs. (Same check as dml_has_output / ddl_has_output — listed here for the caller-specific case.) |
Any combination of tempdb / datasource / caller targets across DQL nodes is otherwise legal — a DQL sink may write to a datasource, stage to tempdb for a later DML node, or return to the caller.
9.3 Multiple-sink example (legitimate)
A pipeline that returns data to the caller AND writes a side effect:
fetch_orders ─┐
├─→ revenue_by_customer ─┬─→ cache_to_warehouse (DQL, output: datasource pg-warehouse)
fetch_customers ┘ └─→ final_report (DQL, output omitted → caller)
Two sinks: cache_to_warehouse (write-back) and final_report (caller return). One node resolves to caller → valid. ✓
9.4 Zero-caller example (pure ETL)
fetch_orders ─→ transform ─→ write_to_warehouse (DQL, output: datasource pg-warehouse)
No node resolves to caller. The pipeline returns execution stats only; no data_ready event is emitted. ✓
10. Output Table Naming Rules
10.1 Rules
- Format:
[a-z0-9_]+, length 1–63 (H2 identifier limits). - Uniqueness is per namespace: all
tempdboutput.tablevalues must be unique among themselves (they share one staging database), anddatasourceoutput.tablevalues must be unique per target datasource. A tempdb table and a write-back table may share a name; two write-backs to different datasources may share a name. - Must not be
tempdbor any name starting and ending with__(reserved namespace). - Recommended convention (not enforced, but LLM prompts and UI defaults should follow):
stg_*for tables staging raw source dataint_*for intermediate transformed tables- Cache tables written to external datasources: name them per the target's convention (often
monthly_revenue_cache,daily_orders_materialized, etc.)
- Stable across versions and environments. Renaming is a breaking change requiring downstream template SQL updates — bump pipeline version.
10.2 Why stable names
Downstream SQL in templates references these names directly: SELECT * FROM stg_orders JOIN stg_customers ON .... The names are part of the pipeline's vocabulary. They must be:
- Predictable for template authors (humans and LLMs).
- Stable so a pipeline promoted from dev to prod doesn't break.
- Traceable in logs —
pipeline.node.fetch_orders.stg_ordersis readable,pipeline.node.fetch_orders.tbl_a1b2c3d4is not.
11. Environment Portability Rules
11.1 What's portable (in the Pipeline JSON)
- Pipeline
id,name,display_name,description,version. - All node IDs, descriptions, types.
- All
output.tablenames. - All
templatereferences ({id, version}). - All
sourcenames (not connection details). - All
parametersdeclarations. - All
settings.
11.2 What's NOT portable (resolved per-environment)
- Datasource connections — the name
pg-prodresolves to different JDBC URLs in dev vs. prod. The mapping is environment-specific config, stored separately (see Datasources spec). - Template bodies — portable, but live in their own registry with their own version history.
- Pipeline UUID — stable across envs if exported and re-imported.
11.3 Promotion workflow
- Export pipeline from dev:
GET /pipelines/{id}/versions/{version}returns the JSON (see §14). - Optionally export referenced templates (separate call or bundled export).
- Import to prod:
POST /pipelines/importwith the JSON. The import:- Checks if pipeline
idalready exists in prod. - If yes, increments
versionand stores the new version. - If no, creates new with
version: 1. - Validates all
sourcenames (excludingtempdb) against the prod datasource registry — fails withpipeline.import.missing_datasourceif any name is unresolvable. - Validates all
output.datasourcereferences (for write-back nodes) against the prod datasource registry. - Validates all
templatereferences against the prod template registry — fails withpipeline.import.missing_templateif any referenced template version is missing.
- Checks if pipeline
11.4 Forbidden in Pipeline JSON
Hard validation errors at write-time. Validation code: pipeline.validation.forbidden_env_specific_value (listed in §12.1).
Scanned fields: every string value in nodes[].source, nodes[].output.datasource, nodes[].output.table, nodes[].id, and all keys/values under settings. (Template bodies are validated separately by the template registry; parameter values are runtime data and are not scanned.)
Detection heuristics applied to each scanned string:
- JDBC URL pattern:
^jdbc:prefix. - Hostname pattern: contains a dot-separated name with a known TLD-like tail or matches
[a-z0-9-]+\.[a-z0-9-.]+:[0-9]+(host:port). - IP address pattern: IPv4 dotted-quad or bracketed IPv6.
- UUID pattern:
[0-9a-f]{8}-[0-9a-f]{4}-...(the pipeline-levelidandownerfields are the only allowed UUIDs). - Credential-shaped:
password=,pwd=,token=,secret=key fragments;dpk_API-key prefix. - Absolute path: leading
/or drive-letter pattern. - Environment literal: exact-match
dev,staging,prod,productionas a whole value.
These heuristics are deliberately conservative (whole-value or prefix matches) — stg_orders containing "stg" is fine; a source of pg-prod is fine (it is a datasource name; the check applies to values that are not references into the datasource registry).
12. Validation Rules
All checks run at pipeline create/update time. A pipeline that fails any check is rejected with HTTP 400 and an error response.
12.1 Structural validations
| Code | Check |
|---|---|
pipeline.validation.schema_version_unsupported |
schema_version is supported (currently only 1) |
pipeline.validation.name_invalid |
name is a path of 2–10 /-separated segments, each [a-z0-9][a-z0-9_.-]{0,63}, ≤ 200 chars total — a folder is required (§3.2). details.reason is folder_required when a folder is the only thing missing, grammar otherwise. Also raised on a PIPELINE node's pipeline.name (§4.9), at path nodes[i].pipeline.name |
pipeline.validation.new_root_requires_confirmation |
The AGENT surface only (094): pipelines_create refuses a name whose ROOT segment has no pipelines under it yet, unless the call carries confirm_new_root: true. details.root is the segment in question and details.existing_roots lists the roots that DO exist, so the agent can reuse one or go and ask. test/ is always allowed. The name is perfectly LEGAL — this is a confirmation, not a grammar failure, which is why it is a distinct code from name_invalid. REST, the UI and pipelines_update are unaffected: a person choosing a folder has already decided, and an update cannot change a name |
pipeline.validation.duplicate_node_id |
All node id values are unique |
pipeline.validation.duplicate_name |
Pipeline name not already taken — HTTP 409, mapped from the pipelines UNIQUE constraint. Uniqueness is PER WORKSPACE (uq_pipelines_workspace_name, since the V4 re-key — two workspaces may each hold a report; the demo's same-name pipelines depend on it) and includes soft-deleted rows: execution history references the name, so a deleted pipeline's name is not reusable in its workspace until hard-deleted. (Corrected twice: an early revision said "live pipelines only"; a 2026-08-08 correction then said GLOBAL, which was the V3 truth and stopped being true at V4.) See Metadata DB §4.4 |
pipeline.validation.duplicate_output_table |
output.table values are unique per namespace: among tempdb targets, and per target datasource among datasource targets (§10.1) |
pipeline.validation.invalid_identifier |
output.table and node id match [a-z0-9_]+ |
pipeline.validation.reserved_identifier |
No node id or table name is tempdb or matches __.*__ |
pipeline.validation.forbidden_env_specific_value |
No scanned field contains env-specific values (hostnames, IPs, JDBC URLs, credentials, UUIDs, paths — §11.4) |
12.2 DAG validations
| Code | Check |
|---|---|
pipeline.validation.dangling_dependency |
Every id in every node's depends_on exists in nodes |
pipeline.validation.cycle_detected |
The dependency graph is acyclic |
pipeline.validation.empty_pipeline |
nodes is non-empty |
pipeline.validation.pipeline_too_large |
nodes count ≤ 1000 (added 2026-08-08). A hard bound so save-time validation cannot be turned into a denial of service — the validator must be crash-proof against hostile input regardless (see the crash-safety rule below), and this bound is defence in depth on top of that. |
Crash-safety (normative, 2026-08-08). Save-time validation runs on attacker-influenced JSON from an
author-scoped principal and MUST NOT be crashable by input. Two specific requirements: (1) graph traversal (cycle detection, reachability) is iterative, never recursive in graph depth — a deepdepends_onchain must not exhaust the JVM stack; (2) any value scanned by the §11.4 portability heuristics is length-bounded before regex evaluation (a scanned value longer than 512 chars cannot be a hostname/URL/UUID/credential and is passed through un-flagged), and the heuristic patterns are bounded in repetition depth. AStackOverflowErrorreaching the request thread from a 2 KB payload is a defect, not a medium.
12.3 Caller-node validations (see §9.2)
| Code | Check |
|---|---|
pipeline.validation.multiple_caller_nodes |
At most one node resolves to output.target: "caller" (explicitly or by omitting output) |
12.4 Node-type validations
| Code | Check |
|---|---|
pipeline.validation.type_invalid |
Each node type is one of DQL, DML, DDL, PIPELINE |
pipeline.validation.dml_has_output |
DML nodes must NOT have an output block |
pipeline.validation.ddl_has_output |
DDL nodes must NOT have an output block |
pipeline.validation.output_target_invalid |
output.target (when the block is present) is one of tempdb, caller, datasource |
pipeline.validation.output_table_missing |
output.target: "tempdb" requires output.table |
pipeline.validation.output_datasource_missing |
output.target: "datasource" requires output.datasource and output.table |
pipeline.validation.output_mode_invalid |
output.target: "datasource" requires mode, and it must be replace or append — a MISSING mode is rejected, never defaulted (2026-08-08: one of the two values TRUNCATEs the target table; guessing is not acceptable) |
12.5 Datasource validations
| Code | Check |
|---|---|
pipeline.validation.unknown_datasource |
Every node source (except tempdb) AND every output.datasource exists in the env's datasource registry |
pipeline.validation.datasource_readonly |
No write-shaped use names a readonly datasource: no DML/DDL node source and no output.datasource resolves to one flagged is_readonly (workspaces design 2026-08-16 §6, D6). The three and only three write shapes, each a distinct runtime raise site, checked as one rule; details carries the node id, the datasource name, and which shape fired (dml_source / ddl_source / output_target). DQL reads and everything tempdb are untouched — the check is on the use, never on the datasource alone |
12.6 Template validations
| Code | Check |
|---|---|
pipeline.validation.template_not_found |
Every template.id exists in the template registry |
pipeline.validation.template_version_not_found |
Every template.version exists for that template id |
pipeline.validation.template_dialect_mismatch |
Template's dialect matches the node's source dialect (for source != "tempdb"). For source: "tempdb", template dialect must match the dialect of the engine declared in settings.tempdb.engine (H2 in v1; DuckDB templates become valid when that engine lands). Applies only to type='sql' templates — the only kind a node can legally reference — each of which carries a non-null dialect by the schema (Templates; template-hierarchy-design §7, 046) |
pipeline.validation.template_type_mismatch |
A DQL/DML/DDL node references a type='html' template — refused at pipeline save. Every template a node can legally reference is sql; details carries template_type (template-hierarchy-design §7, 046) |
pipeline.validation.template_parameter_undeclared |
Dry-render check: every referenced template renders successfully against the pipeline's declared parameters (defaults where present, type-appropriate sample values otherwise). An undefined variable fails here, at save time (§7.4). |
pipeline.validation.template_render_failed |
Dry-render failed for a reason OTHER than an undeclared variable — a type-mismatched built-in, an unresolvable imported macro, an expression error (added 2026-08-08: a render failure at save time is a validation outcome, never an exception escaping the validator, and never mislabeled as template_parameter_undeclared) |
042 B2, not a pipeline.validation.* row: beside the dry render, save also scans each resolved
template's parse tree, and a declared parameter name found inside a ${} interpolation is
refused with template.validation.parameter_interpolated (§13.9, HTTP 400) — declared
parameters are values and bind as :name (Templates §4.5); the message names both forms. The
scan is AST-based (Templates §4.2 reasoning), honours macro-parameter and loop-variable
shadowing, and no spelling hides a live interpolation from it (pinned against Freemarker
2.3.34). The declared set is the pipeline's parameters block including every calculator
output key (078 A1): every key a CALCULATOR node writes joins the set — a single node's
context_key typed by its kind's output, each of a multi-output node's mapped keys typed by
its own output (121) — and is refused in one more position a plain parameter is not — a
conditional's test (<#if x??>, <#elseif x>) — because a derived value gating SQL structure
is the same hole as an interpolated one, one directive earlier.
12.7 Parameter validations
| Code | Check |
|---|---|
pipeline.validation.parameter_type_invalid |
Each parameter type is one of the 10 allowed canonical types (NULL excluded) |
pipeline.validation.parameter_precision_missing |
precision is present when type is DECIMAL. For BIGDECIMAL precision is OPTIONAL — omitted means unbounded, exactly as in the type system's §4 encoding (adjudicated 2026-08-08; a declared parameter follows the same semantics as a derived column) |
pipeline.validation.parameter_name_invalid |
Every parameter key matches [a-z_][a-z0-9_]*, length 1–63 (added/anchored/bounded 2026-08-08 — no leading digit since ${1st_date} is not legal Freemarker; 63-char cap per §6.1) |
pipeline.validation.parameter_scale_missing |
scale is present when type is BIGDECIMAL, or DECIMAL with exact semantics |
pipeline.validation.conflicting_required_default |
required: true and default are not both set |
pipeline.validation.default_type_mismatch |
default survives the FULL §6.3 coercion for its declared type, not merely the JSON-type check — a default that would fail at execution fails at save (D2, adjudicated 2026-08-08) |
12.8 Settings validations
| Code | Check |
|---|---|
pipeline.validation.tempdb_engine_unsupported |
settings.tempdb.engine is H2 (v1) |
pipeline.validation.tempdb_config_invalid |
settings.tempdb.config keys are valid for the chosen engine |
pipeline.validation.node_timeout_invalid |
A node's settings.timeout_seconds is a positive integer no greater than datapipelines.executor.node-timeout-max-seconds (default 900). Refused rather than clamped: an author who writes 14 400 and silently runs at 900 debugs a timeout that says nothing about what they asked for. details carries the node id, the requested value and the ceiling (§4.11) |
pipeline.validation.pipeline_query_timeout_invalid |
settings.query_timeout_seconds (the pipeline-wide SQL statement timeout) is a positive integer no greater than datapipelines.executor.node-query-timeout-max-seconds (default 900). details carries the requested value and the ceiling (§5.3) |
pipeline.validation.node_query_timeout_invalid |
A node's settings.query_timeout_seconds (this node's own SQL statement timeout) is a positive integer no greater than datapipelines.executor.node-query-timeout-max-seconds (default 900); is not declared on a node type that runs no SQL statement (PIPELINE, CALCULATOR); and does not exceed that node's own effective wall-clock deadline (settings.timeout_seconds, else datapipelines.executor.node-timeout-seconds) — a statement budget the node's own lifecycle could never reach is never what an author meant. details names which check failed, the node id, the requested value, and the number it was compared against (§4.11) |
12.9 Composition validations
The PIPELINE-node rules (§4.9). Everything here is computed against the pinned — immutable — child bodies, so the verdicts are stable: editing the child later produces a new version and never invalidates a saved reference.
| Code | Check |
|---|---|
pipeline.validation.pipeline_not_found |
pipeline.name exists in the registry |
pipeline.validation.pipeline_version_not_found |
Pinned version exists for that name |
pipeline.validation.pipeline_self_reference |
Node does not reference its containing pipeline |
pipeline.validation.pipeline_reference_deleted |
Referenced pipeline's entity is DISCARDED — every version discarded, derived since V19 (blocks NEW references only — discarding never breaks an existing pinned reference, mirroring template deletion) |
pipeline.validation.pipeline_reference_not_released |
A PIPELINE node pins a child version that is not RELEASED — composition references reviewed content only (101, versioning §3.5 D58; a DRAFT child can be purged out from under its parent) |
pipeline.validation.pipeline_node_has_source |
PIPELINE node has no source |
pipeline.validation.pipeline_node_has_template |
PIPELINE node has no template |
pipeline.validation.pipeline_parameter_unmapped |
Every required-without-default child parameter is supplied |
pipeline.validation.pipeline_parameter_unknown |
Every supplied key exists in the child's parameters or names one of its CALCULATOR context_keys |
pipeline.validation.pipeline_parameter_type_mismatch |
Literals obey the child target's wire encoding; ${ref} resolves against the parent's Context tiers — a parent parameter, a parent calculator context_key, an org/platform key — to a value of the identical type (an ANY-output key on either side skips the check, typed only by the run) |
pipeline.validation.pipeline_output_on_sideeffect_child |
output absent when the pinned child has zero caller nodes |
pipeline.validation.composition_too_deep |
Static reference-tree depth ≤ configured max (datapipelines.pipelines.max-composition-depth, default 5). Computed iteratively, never recursively in graph depth — §12.2's crash-safety rule applies here too. |
12.10 Calculator-node validations
The CALCULATOR-node rules (§4.10, calculators design §0.3). Every one of them is decided from the body alone — the registry is a deployment constant and the Context's org and platform tiers are configuration — so an author gets the whole verdict at save time, never at 3am.
| Code | Check |
|---|---|
pipeline.validation.calculator_node_incomplete |
A CALCULATOR node declares kind and inputs — a missing key mapping is reported by the shape verdicts below, which name both legal fields |
pipeline.validation.calculator_fields_on_non_calculator |
No other node type carries kind, inputs, context_key or context_keys |
pipeline.validation.calculator_node_has_sql_fields |
A CALCULATOR node carries no template, source or output — it runs no SQL and writes Context keys, not a table |
pipeline.validation.calculator_unknown |
kind names a kind in the registry (Calculators §2) |
pipeline.validation.calculator_input_missing |
Every input the kind declares required is present |
pipeline.validation.calculator_input_unknown |
Every supplied input name is one the kind declares, and every $reference names a Context key something provides — an org or platform key, a declared parameter, or another node's key |
pipeline.validation.calculator_input_type_mismatch |
Literal inputs obey the kind's declared input type and §6.3's wire encoding, and a $reference whose type the body decides (org/platform key, declared parameter, another calculator's output — a multi-output node's key typed by its own output) must match it as well; a LIST input takes a JSON array |
pipeline.validation.calculator_input_unordered |
A $reference to another node's key — and a SQL node binding :that_key — comes from a node that depends_on the producer, directly or transitively. Binding only one of a multi-output node's keys still requires the edge. Sequencing is topology, never array order |
pipeline.validation.calculator_output_collision |
Every key a node writes collides with nothing: not a declared parameter (a calculator may shadow an org or platform key, never a parameter), and not another node's key — one writer per key per pipeline |
pipeline.validation.calculator_output_name_invalid |
Every key a node writes — context_key, or each context_keys value — matches §6.1's [a-z_][a-z0-9_]* |
pipeline.validation.calculator_output_shape_mismatch |
context_key XOR context_keys, and the field fits the kind: context_keys on a single-output kind, context_key on a multi-output kind, BOTH fields present, or neither — all refused. details.reason names which (single_output_kind / multi_output_kind / both_fields / neither_field) |
pipeline.validation.calculator_output_unknown |
A context_keys entry names an output the kind does not declare. details.known_outputs lists the names it does |
pipeline.validation.calculator_outputs_incomplete |
A declared output of a multi-output kind is not mapped in context_keys — no partial mapping, so no reader can bind a key the node never writes. details.missing lists the unmapped outputs |
12.11 MCP-surface entry-point gates (139)
Two save-time refusals that live only on the AGENT surface (pipelines_create / pipelines_update over MCP), beside §12.1's new_root_requires_confirmation: each reads something only the agent loop has (the caller's own mcp.tool.called audit rows), which is why REST, the UI and every non-MCP caller are unaffected. Both check BEFORE any write; both refuse with the fix named.
| Code | Check |
|---|---|
pipeline.validation.table_not_learned |
A saved body's template names a table the CALLING KEY never read the columns of: every [a-z0-9_]{4,} token of each pinned template body (the semantics fact check's tokeniser; every ${…} interpolation span stripped first — a dynamic name is exempt because it is unknowable) that case-fold matches the node's source datasource's catalog listing must appear as a successful datasources_get_columns audit row with that target + table for this key — any time in the key's lifetime (a key learns once; rows written before 139 carry no table and simply do not count). details.tables lists each {datasource, table, clearing_call}. tempdb sources are exempt; a name the catalog does not list is not this check's business (the probe and §12.5 say so); _get_table_stats is NOT required — columns are correctness, stats are performance |
pipeline.validation.door_unacknowledged |
The body's door is a RAW_DATE_PAIR — two or more DATE parameters, no INTEGER parameter named year/quarter/month/*_year, and no CALCULATOR node whose kind declares a DATE output (the window writers: period_start, period_end, prior_period, date_trunc, period_bounds, trailing_periods) — and the call does not carry door_acknowledged: true. details.parameters names the pair; the refusal states rule 13's alternatives (a period parameter, or an anchor date with a window calculator). NONE when there are no DATE parameters; PERIOD otherwise. The flag is the confirm_new_root shape: a decision forced, not a copy |
12.12 Release checks
The checks[] rules (§3.3, 140). One code covers every defect, because to the author they are one category — this check cannot stand as written; path (checks[i].<field>) names the offender. Run-time outcomes — a statement that returns two columns, a datasource that is down — are NOT validation: they are the fail / error verdicts of the server's own check run, reported under §13.17. The read-only / single-statement discipline is enforced at RUN time by the bounded probe the runner rides; save time has no SQL grammar to consult, exactly as for node bodies.
| Code | Check |
|---|---|
pipeline.validation.check_invalid |
A body carries at most 20 checks. Each check's id matches [a-z0-9_]{1,63}, is not tempdb or the reserved __…__ namespace, and is unique within the body (the run rows key on it). name is 1–200 characters. datasource is not tempdb and resolves in this environment's registry (the §12.5 rule as for nodes). sql is non-blank, carries no ${} interpolation (a check has no rendering), and every :name bind names a DECLARED pipeline parameter — the calculator Context is not available to a check; a cast (amount::numeric) is not a bind. expected.kind is the closed list value | range | rows, with the kind's members present and coherent: value needs value and tolerance ≥ 0; range needs min ≤ max; rows needs rows ≥ 0 |
13. Error Code Catalog
Error codes follow the format {domain}.{entity}.{failure}. Codes are lowercase, dot-separated, ASCII. Codes are additive — never reused, never renamed.
13.1 Pipeline validation (write-time)
(See §12 for the full validation catalog. All validation errors use HTTP 400.)
13.2 Pipeline import
| Code | HTTP | Description |
|---|---|---|
pipeline.import.missing_datasource |
400 | Imported pipeline references a datasource name not registered in this env (as source or output.datasource) |
pipeline.import.missing_template |
400 | Imported pipeline references a template version not present in this env |
pipeline.import.version_conflict |
409 | Pipeline id+version already exists with different content (or the id collides with a soft-deleted pipeline's retained id); a same-hash re-import of an existing released version is an idempotent no-op (preserved-version import rules: Versioning §9.2) |
pipeline.import.context_key_missing |
409 | The imported body binds a Context key this deployment does not provide — an org_* key the target's datapipelines.org.* block does not define, most often because the body was authored on a deployment with a key this one lacks (Configuration §3.21). Refused rather than silently defaulted: a promoted pipeline reading a made-up currency or fiscal start produces wrong numbers with no error anywhere |
pipeline.import.hash_mismatch |
400 | Import payload's declared body_hash doesn't match the hash recomputed from its body — transfer corruption or canonicalization drift between app versions (Versioning §9.2) |
13.3 Pipeline execution (run-time)
| Code | HTTP | Description |
|---|---|---|
pipeline.execution.not_found |
404 | Pipeline id or version not found |
pipeline.execution.parameter_required |
400 | Required parameter missing from execution request |
pipeline.execution.invalid_parameter_type |
400 | Parameter value doesn't match declared type — also a calculator context_key supplied at execute time that fails coercion against its kind's output type (§4.10; an ANY-output key accepts any JSON scalar, a container is refused with this same code) |
pipeline.execution.calculator_keys_partial |
400 | The caller supplied a PROPER SUBSET of a multi-output node's keys (121, §4.10). Override is all-or-nothing per node: every key supplied and the node is skipped (provided_by: "caller" on its stats), none and it computes; some is refused before any node runs, with the same shape as invalid_parameter_type. details carries supplied and missing |
pipeline.execution.aborted |
500 | Execution aborted unexpectedly (executor error) |
pipeline.execution.timeout |
504 | Execution exceeded timeout |
pipeline.execution.concurrency_limit |
429 | Too many concurrent executions for this user |
pipeline.execution.not_running |
409 | Cancel requested for an execution that is already terminal |
pipeline.execution.template_unrendered |
400 | MCP-only (139) — pipelines_execute/pipelines_execute_node of a DRAFT pipeline version whose pinned template version is itself a DRAFT written after the calling key's last successful templates_render of it. details.templates carries each {id, version, updated_at, last_render}. RELEASED pins are exempt (they cannot change); a templates_update makes the next execute refuse again until a render. Reads the caller's own audit rows, so REST and the UI are unaffected (§12.11) |
pipeline.execution.instance_lost |
— | Recorded (never returned live) by the crash sweep when a RUNNING execution's instance died (Metadata DB §8) |
13.4 Node execution
| Code | HTTP | Description |
|---|---|---|
pipeline.node.template_not_found |
500 | Template reference resolved at write-time but missing at run-time |
pipeline.node.template_render_failed |
500 | Render error (undefined variable, syntax, etc.) |
pipeline.node.datasource_not_found |
500 | Datasource name resolved at write-time but missing at run-time |
pipeline.node.datasource_readonly |
500 | Datasource resolved at write-time but its live registry entry is readonly at run-time: a write-shaped use (a DML/DDL node source, or any node's output.target: "datasource") of a datasource flagged is_readonly after this pipeline version was saved — the workspaces D10 flip window. The executor re-checks the live registry entry (past the metadata cache) at node execution time, so the flip fails HERE instead of shipping the write; a PIPELINE node's child nodes pass the same backstop in their own execution |
pipeline.node.datasource_connection_failed |
502 | Could not acquire connection to datasource |
pipeline.node.query_execution_failed |
502 | SQL executed but failed (syntax, permission, etc.) |
pipeline.node.query_timeout |
504 | The node's statement outlived its JDBC query timeout — resolved in precedence order: the node's own settings.query_timeout_seconds, else the pipeline's settings.query_timeout_seconds, else the datasource's query_timeout_seconds, else the dialect's operator default (156, §3.2), else datapipelines.executor.node-query-timeout-seconds — and the driver cancelled it. The detail carries timeout_seconds, elapsed_ms and source (node / pipeline / datasource / dialect / application — which tier resolved the effective value). Sibling of pipeline.execution.timeout; distinct from query_execution_failed because "too slow for the budget" and "wrong SQL" want different fixes |
pipeline.node.timeout |
504 | The node outlived its WALL-CLOCK deadline (node.settings.timeout_seconds, else datapipelines.executor.node-timeout-seconds) and the executor stopped it — RENDER through MATERIALIZE, staging included. Distinct from query_timeout, which is ONE statement's budget enforced by the driver: this one is the executor's own and fires whatever the driver does, so a driver that honours neither queryTimeout nor cancel() still cannot hold a node past its budget. A statement that has not returned datapipelines.executor.cancel-grace-seconds after being cancelled is abandoned and logged once with the execution id; the node fails on schedule. The detail carries timeout_seconds, elapsed_ms and phase — the phase is what says whether to make the query cheaper or the staged result smaller |
pipeline.node.staging_failed |
500 | Could not stage ResultSet into tempdb |
pipeline.node.writeback_failed |
500 | Could not write ResultSet to external datasource (output.target: "datasource") |
pipeline.node.writeback_target_missing |
500 | Target table for write-back doesn't exist (preceding DDL node didn't run, or table not pre-created) |
pipeline.node.child_execution_failed |
500 | A PIPELINE node's child execution failed; the detail carries the child's error code and execution id |
pipeline.node.composition_depth_exceeded |
500 | Runtime composition-depth backstop hit; indicates a save-time validation gap, since static depth (§12.9) should catch it first |
pipeline.node.calculator_failed |
500 | A CALCULATOR node's evaluation failed. The detail carries the node id, the kind, the input at fault and the reason — an unknown unit, a format that does not compile, text that does not match its pattern, a zero denominator. Save-time validation (§12.10) has already refused everything decidable from the body, so this is a value the run itself produced |
pipeline.node.sql_parameter_missing |
500 | The rendered SQL references a :name bind parameter the execution context does not declare. Raised before anything executes (042: a missing value bound as null would return wrong data instead of an error); the message names the parameter |
pipeline.node.not_found |
404 | A node-run debug query (MCP §6.2.20) named a node id the resolved pipeline version does not hold (037 E2). details carries the node id and the version searched — after versioning, "no such node" usually means a typo, since the tool's E5 default already prefers the DRAFT body where authoring happens |
pipeline.node.standalone_execution_refused |
400 | A node-run debug query refused because the node has no standalone SQL to run (037 §A/E2): its source is tempdb — the staging database exists only inside a full execution, so use pipelines_execute — or it is a PIPELINE node, which runs a child pipeline, not SQL. details.reason names which (tempdb_source / pipeline_node) |
13.5 Staging
| Code | HTTP | Description |
|---|---|---|
pipeline.staging.value_overflow |
500 | Source value exceeds staging column capacity |
pipeline.staging.precision_overflow |
500 | Source precision exceeds tempdb DECIMAL max |
pipeline.staging.engine_unavailable |
500 | Configured staging engine (e.g., DuckDB) not on classpath |
pipeline.staging.creation_failed |
500 | Could not create tempdb instance |
pipeline.staging.cleanup_failed |
500 | Could not clean up tempdb instance (leaked; logged) |
pipeline.staging.memory_limit_exceeded |
500 | Per-execution memory limit hit |
pipeline.staging.invalid_column_name |
500 | Source column label fails identifier validation or duplicates another in the same result set (Staging §4.5) |
pipeline.staging.table_already_exists |
500 | Staged CREATE TABLE targets a name already staged in this execution (defensive; save-time uniqueness is the primary guard) |
13.6 Type mapping
| Code | HTTP | Description |
|---|---|---|
type_mapping.unknown_source_type |
— | Not an error. Warning returned in response. See Type System §8.2. |
type_mapping.sql_variant |
— | Not an error. Warning for MSSQL sql_variant fallback. |
13.7 Authentication / authorization
Defined and described in Auth §9; cataloged here as the single code registry.
| Code | HTTP | Description |
|---|---|---|
auth.api_key.missing |
401 | No credentials provided |
auth.api_key.invalid |
401 | API key not recognized or revoked |
auth.api_key.expired |
401 | API key past expiration |
auth.session.invalid |
401 | Session JWT malformed or signature invalid |
auth.session.expired |
401 | Session JWT past its expiry |
auth.scope.insufficient |
403 | Principal lacks required scope for this operation |
auth.csrf.invalid |
403 | CSRF token missing or mismatched (browser flows) |
auth.login.domain_not_allowed |
403 | Login rejected: email domain not in allowlist (OIDC or local). The SAME code is raised at INVITE time (113, Auth §4.6) with a 400 — an invitation that could never be honoured at login is refused when it is created; the row's status is the login refusal's, which is a redirect rather than an envelope |
auth.login.user_inactive |
403 | Login rejected: account deactivated (OIDC or local) |
auth.login.bad_credentials |
401 | Local login rejected: email unknown or password incorrect — deliberately identical (Auth §5A.5) |
auth.login.locked |
403 | Local login rejected: account locked after consecutive failures (Auth §5A.3) |
auth.password.change_required |
403 | Session principal must change password before any other operation (Auth §5A.4) |
auth.session.required |
403 | An API-key principal reached a credential-minting operation (admin local-account create, password reset, disable-local, unlock, self-service password change); a key that mints an interactive credential escalates itself into an unpinned session that outlives its own revocation (Auth §5A.7) |
auth.role_required |
403 | The principal's ROLE in the active workspace is below the operation's (RBAC design §2). details.required names the capability, details.held what the membership carries. Distinct from auth.scope.insufficient, which is the CREDENTIAL axis: a viewer's session gets this, a read key on an authoring route gets that (Auth §11A) |
auth.key_issuer_role_lost |
403 | The API key was valid; the person who issued it no longer holds the capability the operation needs (D-R12). Its own code because the recovery differs and cannot be guessed — this key will never work again for this operation, and the fix is a new key from somebody who still holds the role. A demotion takes effect within one validation-cache TTL (60s), so a key can begin refusing mid-session (Auth §7.4) |
auth.key_scope_unavailable |
400 | Key issuance requested a scope keys may not hold. Since RBAC round 1 that is admin: release, promote and membership are human verbs, so no key expresses them (O-2). A 400 and not a 403 — the credential making the request is fine, the requested scope is not one keys have |
auth.key_workspace_inactive |
404 | The workspace this key is pinned to has been deactivated (D-R10). The same 404 a member gets — the owner ruled (2026-09-14) that a deactivated workspace answers not-found on every surface, keys included, so deactivation never becomes a signal. The code stays distinct from workspace.not_found because the holder is a member of that workspace by construction (keys are issued by members and pinned there), so it reveals nothing the holder did not already know — and it is what an operator greps the audit log and this catalogue for. Reactivating the workspace restores the key |
auth.api_key.expiry_invalid |
400 | Key issuance named an expiry this surface cannot use: an unknown preset, a custom date that does not parse, a custom date already in the past, or custom with no date. details.reason is unknown_preset | date_unparseable | date_in_past | date_missing, and details.value echoes the offending value truncated. A 400 rather than a 401/403: the credential is fine, the request body is not (Auth §7.4) |
auth.promotion.key_invalid |
401 | The promotion peer credential was absent, malformed, or did not match the receiver's configured promotion server key. The SAME code answers a receiver that has no key configured at all — promotion is disabled there and fail-closed, and one code keeps the response from telling a wrong key apart from a disabled receiver (Versioning §10.6) |
13.8 Datasource
Defined and described in Datasources §9–10.
| Code | HTTP | Description |
|---|---|---|
datasource.validation.name_invalid |
400 | name fails the identifier rules |
datasource.validation.dialect_invalid |
400 | dialect not in the supported set |
datasource.validation.jdbc_url_malformed |
400 | URL fails the dialect adapter's parse |
datasource.validation.jdbc_url_scheme_invalid |
400 | URL scheme doesn't match the dialect |
datasource.validation.password_missing |
400 | password required on create |
datasource.validation.properties_invalid |
400 | Test pool build rejected a hikari/jdbc property |
datasource.validation.property_empty |
400 | A DECLARED dialect property carries an empty, whitespace-only or null value — refused at register/update (and bootstrap) rather than stored as "" (109 §B); the field names the key |
datasource.validation.query_timeout_invalid |
400 | query_timeout_seconds present but < 1 |
datasource.validation.duplicate_name |
409 | Name already exists — the namespace is global across workspaces too (workspaces design §3: name stays the PK/AAD anchor) |
datasource.validation.workspace_forbidden |
400 | Workspaces D8 refusal: non-admin attempted the global flag (or any mutation of a global datasource), a readonly flip on a global datasource, or a workspace binding the caller is not in — including member CUD while member-datasources-enabled is off (workspaces design §8) |
datasource.in_use |
409 | Delete blocked: pipelines reference this datasource |
datasource.not_found |
404 | Datasource name unknown on a read/mutate/test path (added 2026-08-11, gate C) |
datasource.grant_required |
404 | The datasource exists on the instance but is not GRANTED to the caller's workspace (RBAC design §4, D-R7). A 404 and not a 403 because the 404 rule has no exception for datasources: an ungranted datasource is INVISIBLE, and "this exists, you may not see it" turns the flat, global datasource namespace into an enumeration oracle — every name on the instance probeable one request at a time |
datasource.driver_not_loaded |
400 | JDBC driver JAR for the dialect is not on the classpath |
datasource.lease_in_transaction |
500 | A customer-database connection was requested while a metadata transaction was open on the thread — refused by design (one transaction, one database; see dag-executor §16) |
datasource.validation.lake_dialect_required |
400 | A lake-table operation (register / import / unregister / list) was attempted on a datasource whose dialect is not LAKE (089 §A) |
datasource.validation.lake_namespace_invalid |
400 | A lake table's namespace fails the segment grammar (the pipeline/template §4.1 segment, minus ., 1–9 segments) |
datasource.validation.lake_name_invalid |
400 | A lake table's name fails the segment grammar (the pipeline/template §4.1 segment, minus .) |
datasource.validation.lake_format_invalid |
400 | A lake table's format is not parquet or iceberg (metadata-db §4.15's closed set) |
datasource.validation.lake_location_invalid |
400 | A lake table's location is not an s3:// or file:// URI, or carries a character the SQL-injection boundary refuses (quote, backslash, control character, whitespace) |
datasource.validation.lake_manifest_url_forbidden |
400 | A lake-table import's manifest URL is not under the datasource's own endpoint/bucket — no arbitrary URL fetch (SSRF boundary, 089 §A) |
datasource.lake_table_duplicate |
409 | The (datasource, namespace, name) triple is already registered (mapped from uq_lake_tables_datasource_namespace_name, metadata-db §4.15) |
datasource.lake_table_not_found |
404 | Unregister named a lake table that is not registered — a REGISTRY fact ("not registered"), deliberately distinct from datasource.table_not_found ("not in the database's catalog"): the lake's registry, not the introspector, answers for its tables (123 §A) |
datasource.table_not_found |
404 | A table-addressed read (columns / table stats / preview rows) named a table the namespace's catalog listing does not contain (123 §A, datasources.md §7A). Where the dialect's catalog is complete the table does not exist; where it is privilege-filtered the table may only be invisible to the datasource's credentials — the message says which, and names the nearest listed table when one is close |
datasource.table_forbidden |
403 | The table IS in the catalog listing but the read failed with a permission SQLSTATE — the datasource's credentials cannot read it (123 §A). A 403, not the 404 grant_required argues for: a table on a GRANTED datasource is not invisible (the listing already told the caller it exists), so saying so leaks nothing |
datasource.validation.lake_table_unreadable |
400 | A lake-table registration/import named a table the pre-flight could not read — the view did not create or a one-row scan through it failed (109 §A, datasources.md §8C.1). Refused before storing; the message carries the bounded engine error |
datasource.validation.lake_no_healthy_tables |
400 | A LAKE pool build whose EVERY registered table was refused at the SQL-emission boundary (158, #129, datasources.md §8C.2): nothing would be queryable, so the build is refused instead of serving an empty pool. The message names every refused table and its reason; each refusal is also recorded on its registry row. The fix is the registered content — repair or unregister the named tables |
datasource.lake.table_unavailable |
502 | A pipeline node referenced a registered lake table whose connect-time view creation is recorded as failed (lake_tables.last_error, V20) — the table's view is skipped on every connection (109 §A, datasources.md §8C.2). details carry table and the recorded last_error |
pipeline.execution.datasource_unreachable |
502 | Pre-execution reachability check failed for a referenced datasource |
13.9 Template
Defined and described in Templates §7.
| Code | HTTP | Description |
|---|---|---|
template.validation.syntax_error |
400 | Freemarker parse failure |
template.validation.dangerous_construct |
400 | Forbidden Freemarker construct (SSTI hardening, Templates §4.2) |
template.validation.id_invalid |
400 | Template id fails the identifier rules |
template.validation.new_root_requires_confirmation |
400 | The AGENT surface only (094): templates_create refuses an id whose ROOT segment has no templates under it yet, unless the call carries confirm_new_root: true. Same details.root / details.existing_roots shape, same test/ exemption and same REST/UI exemption as pipeline.validation.new_root_requires_confirmation |
template.validation.dialect_invalid |
400 | dialect not in the supported enum — or absent on a template whose type is not html (046: a dialect is required unless the type is html) |
template.validation.type_invalid |
400 | type not one of sql, html (046, template-hierarchy-design §5.4) |
template.validation.dialect_not_allowed |
400 | dialect is present on a type='html' template — an html template declares no dialect (046, template-hierarchy-design §7). Deliberately distinct from dialect_invalid: presence on the wrong type and an unknown value are different failures |
template.validation.type_immutable |
400 | A payload attempted to change a template's type, which is chosen at create and identical on every version (046, template-hierarchy-design §5.3) |
template.validation.html_entity |
400 | The body contains an HTML entity where a SQL operator belongs (<, >, &, ", ') — the body was HTML-escaped between the author and the server (an agent client, a copy from a rendered page). Refused at save with details.entity and the first line it appears on, because at execution it surfaces as an opaque driver syntax error far from its cause (2026-09-11). |
template.validation.engine_unsupported |
400 | engine not a value v1 supports (only freemarker) |
template.validation.schema_version_unsupported |
400 | schema_version not a value v1 supports (only 1) |
template.validation.is_library_without_macros |
400 | is_library: true but body has no macro definitions or has output outside them |
template.validation.import_not_found |
400 | imports entry references a missing template id/version |
template.validation.import_not_library |
400 | imports entry references a template with is_library: false |
template.validation.import_cycle |
400 | Import graph contains a cycle |
template.validation.import_depth_exceeded |
400 | Transitive import depth > 10 |
template.validation.duplicate_alias |
400 | Two imports entries share an alias |
template.validation.parameter_interpolated |
400 | A declared pipeline parameter name appears inside a ${} interpolation: declared parameters are values and must be referenced as :name, bound as SQL parameters (042; Templates §7.2). The declared set includes every calculator output key (078 A1), which is refused in a conditional's test (<#if x??>, <#elseif x>) too. The message names the parameter and shows the :name form |
template.validation.duplicate_name |
409 | Template name already exists in this workspace — UNIQUE(workspace_id, name), soft-deleted included (the pipeline.validation.duplicate_name shape, for templates; added 2026-08-28, T23) |
template.not_found |
404 | Template id (or id+version) unknown — or soft-deleted on a read/mutate path (added 2026-08-11, gate C) |
template.in_use |
409 | Template delete refused while any pipeline version pins any version of the template (040: the any-version reverse scan; details.referencing_pipelines names who blocks, details.references carries pipeline/node/pipeline-version/pinned-version rows). Deleting is otherwise soft and existing pins keep resolving — the refusal makes "who still uses this?" unmissable, it does not change delete semantics |
template.version.conflict |
409 | Content-hash precondition failed on a template draft write/release/discard — another writer changed it after the caller loaded it (Versioning §4) |
template.version.not_draft |
409 | Template release or discard requested but no DRAFT version exists (Versioning §3) |
template.version.not_released |
409 | Discard targeted a template version that is not RELEASED — a DRAFT is purged, never discarded (101, Versioning §3.1) |
template.version.not_discarded |
409 | Restore targeted a template version that is not DISCARDED (101, Versioning §3.1) |
template.version.last_release |
409 | The purge path refused: a RELEASED template version is never purged, and a template holding any non-draft version is never purged (101, Versioning §3.5 D57) |
template.version.not_eligible |
409 | Manual switch targeted a template version that is not live and posture-eligible (101, Versioning §3.4) |
template.version.confirm_mismatch |
400 | The typed-confirm guard on the irreversible template purge dialogs (102): the confirm form field did not name what the dialog asked the user to type — the version (v4) on a version purge, the template's name on the entity purge. Checked BEFORE the lifecycle service runs (UI §5.1 typed-confirm) |
template.authoring.disabled |
403 | An authoring write (create, update/draft, release, discard, delete) on a server with datapipelines.deployment.authoring-enabled=false — the template mirror of pipeline.authoring.disabled (Versioning §5.5); reads, execution and import are unaffected |
13.10 Result retrieval
Defined and described in REST API §7.
| Code | HTTP | Description |
|---|---|---|
result.execution_not_found |
404 | Execution ID unknown |
result.execution_incomplete |
409 | Execution not yet completed |
result.execution_failed |
410 | Execution failed; no result exists |
result.expired |
410 | Result TTL elapsed; re-run the pipeline |
result.format_unsupported |
400 | Unknown format parameter |
result.too_large |
500 | Caller result exceeded datapipelines.result.max-size-bytes; execution failed |
result.storage_unavailable |
500 | Redis unavailable at result-write time; execution failed |
13.11 Rate limiting / idempotency
| Code | HTTP | Description |
|---|---|---|
rate_limit.exceeded |
429 | Per-user rate limit hit (single code for all layers — REST, MCP, login) |
rate_limit.unavailable |
429 | The limiter could not decide (Redis unreachable); the request is refused, not admitted — the limiter fails closed |
idempotency.key_reused_for_different_request |
409 | Same Idempotency-Key submitted with a different request body |
13.12 Workspace resolution
Raised by the workspace resolution layer (Auth §5) — the per-request
DP-Workspace switch and API-key pinning — and by the workspace CRUD and membership surfaces.
The 404 rule governs (RBAC design D-R5). A workspace the caller cannot reach — unknown name,
not a member, or deactivated — is ONE answer: workspace.not_found, the same status and body a
genuinely missing row produces. Never a 403. workspace.membership_required survives in exactly
one place, where no workspace was ADDRESSED at all (a principal with zero memberships), because
there is no name there whose existence a 403 could leak.
| Code | HTTP | Description |
|---|---|---|
workspace.membership_required |
403 | The principal has no active workspace at all (zero memberships). Not used for an addressed workspace — that is workspace.not_found |
workspace.header_forbidden |
400 | DP-Workspace sent on an API-key request; a key's workspace is pinned at issuance and cannot switch |
workspace.session_required |
403 | An API-key principal reached a session-only workspace action (the UI's create/members/delete/switch); the key's workspace is pinned at issuance and switch mints a session, so the credential class is refused outright |
workspace.not_found |
404 | The addressed workspace does not exist FOR THIS CALLER: unknown name, non-member, or deactivated — one answer for all three (D-R5), so nothing about a workspace is probeable. Also refuses a promotion batch naming a workspace the receiver does not have (O-4); nothing is auto-created |
workspace.last_admin |
409 | The membership change would leave the workspace with no admin. Its own code rather than workspace.in_use: the caller's next step is "give someone else the admin role first", which is a different instruction from "empty the workspace first" |
workspace.inactive |
404 | A super admin addressed a deactivated workspace on a path that must refuse it (D-R10). Members never see this code — for them a deactivated workspace is workspace.not_found, because deactivation must not become a signal — and API keys get auth.key_workspace_inactive |
workspace.invitation.not_found |
404 | The INVITATION addressed does not exist (113): revoked already, never created, or created for another workspace. Its own code rather than workspace.not_found because the workspace itself resolved fine — the not-found thing is the invitation — and one answer for all three causes keeps an admin from probing which emails hold pending invitations off the status code (Auth §4.6) |
workspace.validation.name_invalid |
400 | Workspace name fails [a-z0-9_-]+, 1–63 |
workspace.validation.duplicate_name |
409 | Workspace name exists (global namespace, soft-deleted included — house rule) |
workspace.in_use |
409 | Delete blocked: workspace still owns non-deleted pipelines/templates/datasources. Deactivation (D-R10) is the operation that needs no such check — it purges nothing |
13.13 Versioning / draft-release lifecycle / promotion
Defined and described in Versioning. Codes are additive; the draft/release lifecycle and the preserved-version import rules live there (this table is the catalog entry, Versioning is the semantics).
| Code | HTTP | Description |
|---|---|---|
pipeline.version.conflict |
409 | Content-hash precondition failed on a draft create/write, release, or discard — another writer changed the pipeline after the caller loaded it; details carries current_body_hash, current_status, updated_by, updated_at (Versioning §4.2) |
pipeline.version.not_draft |
409 | Pipeline release or discard requested but no DRAFT version exists (Versioning §5.3/§5.4) |
pipeline.version.not_released |
409 | Discard targeted a version that is not RELEASED — a DRAFT is purged, never discarded; a DISCARDED version is already retired (101, Versioning §3.1) |
pipeline.version.not_discarded |
409 | Restore targeted a version that is not DISCARDED — there is nothing to restore (101, Versioning §3.1) |
pipeline.version.pinned |
409 | Discard or purge refused: a LIVE version of another pipeline exact-pins this version — details names the pinning entities (101, Versioning §3.5 graph rule 1) |
pipeline.version.last_release |
409 | The purge path refused: a RELEASED version is never purged, and an entity holding any non-draft version is never purged — discard is per version, the entity stays; restore or release something first (101, Versioning §3.5 D57) |
pipeline.version.not_eligible |
409 | Manual switch targeted a version that is not a live, posture-eligible version — DISCARDED, missing, or a DRAFT under a hardened posture (101, Versioning §3.4 D60) |
pipeline.version.confirm_mismatch |
400 | The typed-confirm guard on the irreversible purge dialogs (102): the confirm form field did not name the version the dialog asked the user to type (v4). Checked BEFORE the lifecycle service runs — a mismatch never reaches the verb (UI §5.1 typed-confirm, the second use after the CLI's --clean) |
pipeline.release.template_not_released |
409 | Pipeline release blocked: the draft pins template version(s) still in DRAFT — release those templates first, or consent to the cascade with release_pinned_templates=true (142; details.pins_not_released lists every unreleased pin); a DISCARDED or MISSING pin is never releasable (Versioning §5.3 precondition 2) |
pipeline.promotion.not_released |
409 | Promotion selected a pipeline whose candidate version is not RELEASED — drafts are never promoted (Versioning §10.3 guard 1) |
pipeline.promotion.not_newer |
409 | Promotion push of a version not greater than the target environment's current version for that pipeline — same-version pushes are a bug, not a no-op (Versioning §10.3 guard 2) |
pipeline.promotion.missing_datasources |
409 | Promotion pre-validation (Versioning §10.5): one or more datasource names the batch references do not exist on the target. Reported ONCE for the whole batch, details.missing_datasources naming every absent one, before anything is pushed — the target is left byte-unchanged rather than failing mid-batch |
pipeline.promotion.target_is_authoring |
409 | Promotion into a target whose authoring-enabled is true — dev is where drafts live, and a receiver never authors (Versioning D7/§10.1). Raised by the receiver, so a misconfigured sender cannot push into an authoring deployment |
pipeline.promotion.target_unreachable |
502 | The promotion target did not answer, timed out, or answered something that is not this API — a transport failure on the SENDER, never a refusal by the receiver (a receiver's refusal is re-raised with the receiver's OWN code). details carries target and, when there was one, target_status. Logged WARN without a stack: the operator's own peer deployment being down is not a defect in this one |
pipeline.authoring.disabled |
403 | An authoring write (create, update/draft, release, discard, delete) on a server with datapipelines.deployment.authoring-enabled=false — a promotion receiver never authors (Versioning D7/§5.5). Reads, execution and import are unaffected. The template mirror is template.authoring.disabled (§13.9) |
13.14 Published endpoints
The contract and the complete status table are REST API §19; the key kinds and the hierarchical bindings are Auth §7.7. This table is the catalog entry — it lands with the constants (a catalogued code split from its constant leaves the build red between the two), and the two sections above land with the surfaces that raise them.
The bare endpoint.* codes are two-segment: the domain has no entity dimension, the same shape as
datasource.in_use and template.not_found. The endpoint.request.* family is the request
validator's, and it is the only family a caller of a published endpoint can provoke by changing
their query string — every one of its codes is reported inside one endpoint.request.invalid
response whose details.errors[] names every defect at once.
| Code | HTTP | Description |
|---|---|---|
endpoint.path_invalid |
400 | path_pattern is not a legal path (REST API §19.1): wrong segment alphabet, more than 10 segments, over 200 characters, a trailing slash, a **, or a malformed {variable} |
endpoint.path_conflict |
409 | The pattern could match the same URL as an already-published one (/a/{x} against /a/b); details.conflicting_path names the other. Ambiguity is refused, never resolved by precedence — at request time at most one pattern may match |
endpoint.path_variable_unknown |
400 | A {variable} in the path names no declared parameter of the pipeline's released version |
endpoint.pipeline_not_readonly |
409 | The pipeline is not side-effect-free: a DML/DDL node, or a DQL node writing back to a datasource, transitively through PIPELINE children; details.node_id names the offender. Raised at publish. The SAME code is returned as 503 at serve time (REST API §19.4) when a later release breaks the rule under a live endpoint — the endpoint is not run |
endpoint.pipeline_not_released |
503 | The published pipeline has no servable version: nothing its pointer names is eligible — RELEASED always, a DRAFT only under development posture (Versioning D63) |
endpoint.not_found |
404 | No endpoint matches the request path. Deliberately identical for an unknown path and a disabled one — distinguishing them would let an unauthenticated caller enumerate the registry |
endpoint.method_not_allowed |
405 | Any method but GET under /api/x; the response carries Allow: GET |
endpoint.not_acceptable |
406 | An Accept this surface cannot satisfy (v1 serves application/json) |
endpoint.key_not_bound |
403 | The presented key is not among those bound at the first ancestor of the request path carrying any binding. A deeper binding REPLACES an inherited one, so a key bound higher up is refused rather than inherited (Auth §7.7) |
endpoint.key_kind_refused |
403 | The key's kind is wrong for what it is doing: an endpoint key outside its bound endpoints and the cursor of executions it started; an endpoint key presented to an endpoint with no binding on any ancestor (where only user keys with execute are accepted — an unbound endpoint key authorises nothing); a server key anywhere but the promotion routes it opens as DP-Promotion-Key (091); or an issuance whose kind contradicts its scopes/bindings. details.reason names which |
endpoint.promotion.key_missing |
409 | A promotion batch carries an endpoint binding naming an API key by name that the target deployment does not have. Reported before anything is pushed, like every other promotion pre-validation, so the target is left byte-unchanged |
endpoint.request.invalid |
400 | One or more request defects; details.errors[] carries {parameter, code, message} for every one of them (REST API §19.3) |
endpoint.request.parameter_unknown |
400 | A query parameter the version does not declare. Strict on purpose: a typo that silently ran the default would return plausible, wrong rows |
endpoint.request.parameter_repeated |
400 | The same query key appeared more than once; an endpoint takes one value per parameter |
endpoint.request.value_too_large |
400 | A single parameter value over 4 KB |
13.15 Learned semantics
The learned semantic layer (design record §3.1, §7.1, O-4): what semantics_record / semantics_retire refuse. Two-segment codes, like datasource.not_found — the domain has no entity dimension; every code is about one fact. Landed with the constants (PipelineErrorCodes.Semantics, mirrored in modules/datasources's SemanticsErrorCodes because the recorder lives below pipeline-contract) and their ApiErrorCatalog rows.
| Code | HTTP | Description |
|---|---|---|
semantics.kind_invalid |
400 | kind is not in the closed list (Enums §19), or is not a kind of the requested scope — the nine data kinds are DATASOURCE, the three business kinds WORKSPACE; details.kind and details.scope |
semantics.fact_invalid |
400 | fact is outside its 8–1000 character window, refs is empty on a DATASOURCE-scope kind (a unit without a column is meaningless; a WORKSPACE-scope definition/exclusion/preference may carry none — a rule that spans datasources, 136 §B), or evidence_summary exceeds 300 characters; details.field names which |
semantics.ref_unresolved |
400 | A ref does not resolve against the datasource's LIVE introspection — the table is not there, or the column is not one of its columns. The store never starts stale (§3.1); details.ref carries the offending {schema, table, column} |
semantics.ref_mismatch |
400 | The fact (or evidence_summary) text names a table the refs do not, and the name is a NEAR-MISS: an identifier-shaped token within an edit distance of 2 of a listed table without being one (the same nearest-name rule as datasource.table_not_found). details.token names the offending word and details.suggestion the nearest listed table. A token that spells a listed table exactly is not refused: the tool appends that table to the stored refs and reports it in refs_added (129, owner ruling 2026-09-14). A datasource with no catalog listing (an empty registry) skips the check |
semantics.evidence_refused |
400 | evidence_sql is not a single read-only SELECT/WITH (the sql_probe classifier's rule), or names a :parameter — evidence binds nothing. Refused before any connection opens |
semantics.evidence_failed |
400 | evidence_sql ran once through the probe path and the database refused it, or it exceeded the probe's timeout; the fact is NOT recorded — evidence that does not run is not evidence. details.reason is execution_failed or timeout; an unreachable datasource stays pipeline.execution.datasource_unreachable (502) |
semantics.duplicate |
409 | An identical LIVE fact — same (scope, workspace, datasource, kind, refs, fact) — already exists; details.existing_id names it. Refused rather than rate-limited (O-4): an agent looping on the same fact learns nothing from a second row. A retired fact is not a duplicate |
semantics.not_found |
404 | The fact addressed by id (or by supersedes) does not exist, or is a WORKSPACE fact of another workspace — the D-R5 answer, identical for both |
13.16 MCP surface
The MCP server's own refusals (120). Two-segment, like semantics.not_found — the domain has no entity dimension. The resource surface's unknown-URI answer is deliberately absent here: it is the JSON-RPC protocol's RESOURCE_NOT_FOUND (MCP §9.1), which never travels in a §9.2 content envelope. Landed with the constant (PipelineErrorCodes.Mcp) and its ApiErrorCatalog row.
| Code | HTTP | Description |
|---|---|---|
mcp.doc_not_found |
404 | docs_get named a document the shipped skill does not carry (MCP §6.2.41); details.name and details.known_docs — the tool-surface answer to a resource read's not-found |
13.17 Release checks
The release-check run's refusals (140, §3.3). Checks are opt-in: a version with no checks[] can never produce one of these — the gate runs only where the body declares them. Save-time shape defects are §12.12's pipeline.validation.check_invalid; this section is the RUN-time family, raised on the release path against the server's own fresh run — the author never supplies an observed value, so there is nothing here a caller could fake.
| Code | HTTP | Description |
|---|---|---|
pipeline.check.failed |
409 | release refused — a check on the version failed or errored; details.checks lists expected/observed/message per failing check; override requires override_checks_reason (≥ 10 chars) |
14. Pipeline Lifecycle Operations
This section sketches the CRUD operations. Full HTTP details are in the REST API spec.
| Operation | Method & Path | Notes |
|---|---|---|
| Create pipeline | POST /pipelines |
Body: pipeline JSON without id, version, created_at, updated_at (server assigns). Version 1 lands DRAFT and current_version stays null — releasing is a human action (Versioning §3.6, D55). |
| Get pipeline (working version) | GET /pipelines/{id} |
Returns the working version: the DRAFT when one exists, else the latest release. |
| Get pipeline (specific version) | GET /pipelines/{id}/versions/{version} |
|
| List pipeline versions | GET /pipelines/{id}/versions |
Returns version metadata. |
| Update pipeline (writes the draft) | PUT /pipelines/{id} |
Body: full pipeline JSON. Copy-on-write: first change after a release creates a DRAFT of the next version; further changes overwrite the draft in place. Hash-preconditioned. Never appends a released version. See Versioning. |
| Release pipeline (lock) | POST /pipelines/{id}/release |
Human/UI action: promotes the DRAFT to RELEASED, bumps current_version. Hash-preconditioned; requires pinned template versions released. |
| Discard draft | POST /pipelines/{id}/draft/discard |
Deletes the DRAFT (or flips it to DISCARDED if an execution references it). |
| Delete pipeline | DELETE /pipelines/{id} |
Soft delete. Executions of deleted pipelines fail with pipeline.execution.not_found. |
| List pipelines | GET /pipelines |
Filterable by owner, datasource, etc. |
| Execute pipeline | POST /pipelines/{id}/execute |
Body: {parameters: {...}}, optional version. With no version it runs the working version — the draft when one exists, else the latest release (Versioning §7.2, D56). Returns SSE stream. See REST API spec. |
| Import pipeline | POST /pipelines/import |
Body: full pipeline JSON (possibly with id). A carried version is honored (preserved-version import, Versioning §9.2); absent, the next local version is allocated. |
| Export pipeline | GET /pipelines/{id}/export |
Returns pipeline JSON + manifest of referenced templates. Refused with 409 pipeline.promotion.not_released when nothing has been released — an export feeds an import that lands RELEASED. |
15. Stability Promise
The Pipeline Contract is versioned, additive-only.
15.1 What is frozen in v1.1
- The top-level Pipeline JSON shape (
schema_version,id,name,nodes,settings, etc.). - The Node JSON shape (
id,type,source,template,output,depends_on). Gained the optionalpipeline/parametersfields for PIPELINE nodes 2026-08-17 — additive per §15.2; readers that predate them ignore the keys. - The
typeenum:DQL,DML,DDL.PIPELINEadded 2026-08-17 as an additive value per §15.2 — old documents remain valid; a deserializer that predates it rejects it withtype_invalid. - The
output.targetenum:tempdb,caller,datasource. - The
output.modeenum:replace,append. - The parameter descriptor shape.
- The reserved literal
"tempdb". - The error code format and all error codes defined in §13.
- The naming rules: pipeline
nameis the §3.2 path grammar (widened from[a-z0-9_]+, 1–63 in 2026-09-05 — see Template Hierarchy §14.2 for the one shape that stopped being legal and why it needs no migration); node ids,output.tableand parameter keys keep the identifier rules they always had ([a-z0-9_]+, 1–63, and[a-z_][a-z0-9_]*respectively) — 067 widened the NAME rule only. - The caller-node resolution rules (§9): omitted
output→caller, at most one caller node, zero legal.
15.2 What is NOT frozen
- New optional fields may be added (e.g.,
tags,metadata) — non-breaking. - New error codes may be added — non-breaking.
- New
output.targetvalues may be added (e.g.,kafka,s3) — non-breaking for v1 clients. - New
typevalues may be added (e.g.,EXPRESSION,HTTP) — non-breaking for v1 clients. - New
settings.tempdb.enginevalues may be added (e.g.,DUCKDB) — non-breaking.
15.3 Evolution rules
schema_versionbumps on any non-additive change.- New versions are documented with migration notes.
- Old pipeline JSON continues to load and execute under its original
schema_version(we maintain backward-compatible readers).
15.4 Which version counter governs what
Three independent counters exist across the system — none of them is "the version" alone:
| Counter | Lives on | Governs | Bumped when |
|---|---|---|---|
Doc revision (v1.2 in this doc's header) |
Each spec's Status line + Change Log | The prose contract | The spec text changes |
schema_version (integer field) |
Pipeline JSON, Template JSON, and the result schema envelope — three independent counters that happen to share a field name | The JSON shape of that entity family | A non-additive change to that family's JSON shape |
Entity version (integer field) |
Each pipeline / template instance | The content of one entity | Every save (immutable-per-version) |
A schema_version bump in the Type System's schema envelope says nothing about Pipeline JSON, and vice versa — clients must interpret schema_version in the context of the document that carries it.
16. Worked Examples
16.1 Minimal pipeline: single-source read
{
"schema_version": 1,
"name": "acme/reporting/active_users",
"display_name": "Active Users",
"description": "List all active users.",
"parameters": {},
"nodes": [
{
"id": "active_users",
"description": "Read active users from PG.",
"type": "DQL",
"source": "pg-prod",
"template": {"id": "acme/reporting/active_users.sql", "version": 1},
"depends_on": []
}
]
}
Single DQL node, no output block → resolves to caller. This is the caller node. ✓
16.2 Two-source join (most common pattern)
See §3.1 for the full example.
16.3 Pipeline with write-back
{
"name": "acme/finance/monthly_revenue_etl",
...
"nodes": [
{fetch_orders, DQL, pg-prod → tempdb stg_orders},
{fetch_customers, DQL, mysql-prod → tempdb stg_customers},
{revenue_by_customer, DQL, tempdb → tempdb int_revenue},
{
"id": "write_to_warehouse",
"type": "DQL",
"source": "tempdb",
"template": {"id": "acme/finance/select_revenue.sql", "version": 1},
"output": {
"target": "datasource",
"datasource": "pg-warehouse",
"table": "monthly_revenue_cache",
"mode": "replace"
},
"depends_on": ["revenue_by_customer"]
},
{
"id": "return_report",
"type": "DQL",
"source": "tempdb",
"template": {"id": "acme/finance/report.sql", "version": 1},
"output": {"target": "caller"},
"depends_on": ["revenue_by_customer"]
}
]
}
Two sinks: write-back side effect + caller return. Validation passes (exactly one caller target). ✓
16.4 Pipeline with DDL index creation
{
"id": "create_idx",
"type": "DDL",
"source": "tempdb",
"template": {"id": "acme/finance/create_idx_revenue.sql", "version": 1},
"depends_on": ["revenue_by_customer"]
}
Where the template is:
CREATE INDEX IF NOT EXISTS idx_int_revenue_customer
ON int_revenue (customer_id)
A downstream JOIN node then benefits from this index when querying int_revenue.
16.5 Pipeline with DML audit record
{
"id": "record_execution",
"type": "DML",
"source": "pg-meta",
"template": {"id": "acme/finance/record_execution.sql", "version": 1},
"depends_on": ["return_report"]
}
Template:
INSERT INTO pipeline_executions (pipeline_id, executed_at, status)
VALUES ('${pipeline_id}', CURRENT_TIMESTAMP, 'SUCCESS')
Side-effect only. No output block. No data returned. ✓
17. Implementation Notes (Non-Normative)
17.1 Where this lives in the codebase
The Pipeline Contract is implemented in the pipeline-contract Gradle module:
Pipelinedata class (top-level)Nodedata classNodeTypeenum (DQL,DML,DDL)NodeOutputsealed interface (Tempdb,Caller,Datasourceimplementations — matches DAG Executor §4)PipelineSettingsdata class (withTempdbSettingsnested)Parameterdata classTemplateRefdata class ({id, version})PipelineValidator— runs all §12 validationsPipelineSerializer/PipelineDeserializer— Jackson-based JSON ser/deser; the deserializer resolves an omittedoutputon DQL nodes toNodeOutput.CallerExecutionContext— runtime mutable map (see §7)CallerNodeResolver— implements §9 resolutionPipelineRepository— persistence viaNamedParameterJdbcTemplate
17.2 Validation pipeline
JSON input
→ PipelineDeserializer (syntactic parse; omitted output → Caller)
→ PipelineValidator (semantic validation, §12 — includes template dry-render)
→ CallerNodeResolver (resolve the caller node via §9)
→ Pipeline entity (validated, ready to persist or execute)
Validation is exhaustive — all checks run, all failures collected, returned together (not fail-fast). This gives authors the full picture on a broken pipeline.
A handful of §12 codes are necessarily raised at the DESERIALIZATION step rather than by the validator — verdicts on wire values that have no typed representation (type_invalid, output_target_invalid, output_mode_invalid, parameter_type_invalid, tempdb_engine_unsupported). They are still collected exhaustively across the whole document before binding fails, preserving the full-picture guarantee (2026-08-08).
17.3 Persistence
Pipelines are persisted in the app's own Postgres metadata DB. The DDL authority is Metadata DB — tables pipelines, pipeline_versions, pipeline_executions. This spec does not restate column lists.
18. Open Questions / Future Additions
Out of scope for v1.1, tracked for future:
- Calculators (v2): pre-execution transformers that read Context and write additional keys (
quarterfromdate, etc.). - Non-SQL node types (v2):
EXPRESSIONnodes (no SQL, transform data in/out via expression language),HTTPnodes (call external API, stage response). - Conditional execution (v2): skip nodes based on a Context expression.
- Retries (v2): per-node retry policies.
- Streaming (v2): pipe rows between nodes instead of full materialization.
- Pipeline-level scheduling (v2): declare cron-style schedules in the pipeline.
- Additional
output.targetvalues (v2):kafka(publish to topic),s3(write object),email(send report),webhook(POST result). - Auto-create target table for write-back (
output.auto_create: true) — v1.1 candidate. - Additional
settings.tempdb.enginevalues — DuckDB in v1.1 or v2.
Appendix A: Change Log
| Date | Version | Author | Change |
|---|---|---|---|
| 2026-09-17 | v1.24 | 158 (#129) | §13.8 gains datasource.validation.lake_no_healthy_tables (400): a LAKE pool build whose EVERY registered table was refused at the SQL-emission boundary is refused outright — nothing would be queryable, and the pre-fix shape failed the same build cryptically (a SET search_path naming a schema that was never created) on the first physical connection. The message names every refused table and its reason; each refusal is recorded on its registry row. Additive per §15.2; landed in the same commit as its DatasourceErrorCodes constant. |
| 2026-09-17 | v1.23 | 156 query timeout settings (#2) | Pipeline and node SQL statement timeout overrides. New §4.11 field node.settings.query_timeout_seconds and new §5.3 settings.query_timeout_seconds (Future settings renumbered §5.3 → §5.4); precedence node > pipeline > datasource > per-dialect operator default (configuration.md §3.2) > node-query-timeout-seconds, bounded by the same ceiling as node.settings.timeout_seconds (datapipelines.executor.node-query-timeout-max-seconds, default 900). New §12.8 codes pipeline.validation.pipeline_query_timeout_invalid and pipeline.validation.node_query_timeout_invalid (refused on a non-SQL node type or when the statement budget exceeds the node's own wall-clock deadline, both refused rather than clamped). §13.4's pipeline.node.query_timeout detail gains source naming the resolved tier. Body-hash neutral: both keys are absent on every stored pipeline/node and serialize back absent. Additive per §15.2. |
| 2026-09-15 | v1.21 | 139 the entry-point checks | §12.11 (new) — two MCP-surface save-time gates: pipeline.validation.table_not_learned (a saved template names a listed table the calling key never datasources_get_columns'd; audit-row read, key-lifetime window, ${…} and tempdb exempt) and pipeline.validation.door_unacknowledged (a RAW_DATE_PAIR body refused until door_acknowledged: true, the confirm_new_root shape). §13.3 — pipeline.execution.template_unrendered (400): execute of a DRAFT whose pinned DRAFT template postdates the key's last successful render. All three MCP-only: they read the caller's own mcp.tool.called audit rows. REST and the UI unaffected. Additive per §15.2; both validation codes live in §12.11, and PipelineErrorCodesSpecDriftTest pins both sides. |
| 2026-09-15 | v1.22 | 140 | Release checks: optional §3.3 checks[] — server-run read-only statements with an expected value, versioned with the body like nodes, body-hash neutral; the agent supplies the query and expectation, never an observed value. §3.2 gains the field row; new §12.12 (pipeline.validation.check_invalid, one code for every declaration defect) and new §13.17 (pipeline.check.failed, 409 — release refused on a failing check, overridable with a reason). Additive per §15.2. |
| 2026-09-14 | v1.20 | 136 §B refs optional on a WORKSPACE rule | §13.15 semantics.fact_invalid: the empty-refs arm is now DATASOURCE-scope only — a WORKSPACE-scope definition/exclusion/preference may carry no refs (a rule that spans datasources is recorded once, against the datasource the question is mostly about; V26 relaxes chk_learned_facts_refs the same way). No new code. |
| 2026-09-13 | v1.19 | 123 table-not-found | §13.8 gains datasource.table_not_found (404) and datasource.table_forbidden (403) — the introspector's three-state table resolution (datasources.md §7A): every table-addressed read (columns / table stats / preview rows) resolves the table against the namespace's catalog listing first; absent is a 404 whose message says whether the dialect's catalog is complete or privilege-filtered and names the nearest listed table, present-but-unreadable (a permission SQLSTATE on the read) is a 403. An unknown table is no longer an empty answer; an existing table with zero readable columns stays one. datasource.lake_table_not_found is unchanged — a registry fact, not a catalog one. Additive per §15.2. |
| 2026-09-13 | v1.18 | 121 calculator multi-output | A CALCULATOR kind may declare a named output set (§4.10, both JSON shapes): a single-output kind writes its value under context_key exactly as before; a multi-output kind (the catalog says which) maps EVERY declared output through context_keys — never both fields, never neither, no partial mapping. §12.10 gains calculator_output_shape_mismatch, calculator_output_unknown (details.known_outputs) and calculator_outputs_incomplete (details.missing); §13.3 gains pipeline.execution.calculator_keys_partial — caller override of a multi node's keys is all-or-nothing, a proper subset refused before any node runs with the same shape as invalid_parameter_type. Every mapped key obeys every rule a single key always has (name, collision, ordering, the §12.6 guarded set, derived inputs) and is typed by its own output. Body-hash neutral: context_keys is absent on every stored single-output body and serializes back absent; a single node's stats row is byte-identical, a multi node's carries context_values. Additive per §15.2. |
| 2026-09-11 | v1.17 | 118 learned semantic layer | New §13.15 Learned semantics — seven two-segment semantics.* codes (kind_invalid, fact_invalid, ref_unresolved, evidence_refused, evidence_failed — all 400; duplicate 409; not_found 404), landed with PipelineErrorCodes.Semantics, the datasources mirror SemanticsErrorCodes (the recorder lives below this module) and their ApiErrorCatalog rows; §13 rows 163 → 170. |
| 2026-09-10 | v1.16 | 109 §B empty dialect properties | §13.8 gains datasource.validation.property_empty (400): a DECLARED dialect property carrying an empty, whitespace-only or null value is refused at register/update — and so at bootstrap, which saves through the same validator — instead of being stored as "" (the catalog.ref: "" incident). The field names the key; a bootstrap field whose whole value is one ${VAR} that resolves set-but-empty is OMITTED (the operator's off-switch), so the shipped defaults still boot. Additive per §15.2. |
| 2026-09-10 | v1.15 | 109 §A lake view isolation | §13.8 gains two rows: datasource.lake.table_unavailable (502) — a pipeline node referenced a registered lake table whose connect-time view creation is recorded as failed (lake_tables.last_error, V20); details carry table and the recorded last_error — and datasource.validation.lake_table_unreadable (400) — the registration/import pre-flight refusal: the candidate table's view did not create or a one-row scan through it failed, refused BEFORE storing with the bounded engine error as the message. Additive per §15.2. |
| 2026-09-09 | v1.14 | 108 executor hardening | New §4.11: a node may declare its own WALL-CLOCK deadline, settings.timeout_seconds — the middle of three budgets whose precedence §4.11 now states as one table (execution ≥ node ≥ statement). §4.6 gains the settings row and states that depends_on is data flow only, never an edge added to avoid contention. §12.8 gains pipeline.validation.node_timeout_invalid (the ceiling is datapipelines.executor.node-timeout-max-seconds, refused rather than clamped) and §13.4 gains pipeline.node.timeout (504) — the executor's own bound, which fires whatever the driver does. Body-hash neutral: settings is absent on every stored node and serializes back absent. Additive per §15.2. |
| 2026-09-09 | v1.13 | T202 node query timeout | §13.4 gains pipeline.node.query_timeout (504): a statement cancelled by its own JDBC query timeout reports the timeout, not query_execution_failed + driver text. |
| 2026-09-08 | v1.12 | 099 draft-first (D55/D56) | §14's operation table: POST /pipelines lands v1 DRAFT with a null pointer, GET /pipelines/{id} is the working version, POST …/execute defaults to the working version, and GET …/export refuses a never-released pipeline with pipeline.promotion.not_released. No new error code and no §13 row: every refusal reuses a catalogued one. |
| 2026-09-06 | v1.12 | 078 composition mapping | The parent→child mapping half of the calculator input ruling (v1.11): a PIPELINE node's parameters may now map onto a child CALCULATOR context_key as well as a declared parameter (supplied → the child's node is skipped, §4.10's rule composed), and a "${ref}" value resolves against all three parent Context tiers — a declared parameter, a parent calculator context_key (typed by its kind's output), an org/platform key (org STRING, platform canonical) — type-checked against the target with the unchanged codes, messages naming the tiers; an ANY-output key on either side skips the check (typed only by the run, A6's convention). No auto-passthrough: identically spelled parent/child calculator keys are not implicitly mapped — only explicit entries cross. The read surfaces list calculator keys under parameters as {"type", "required": false, "derived": true} ("ANY" for ANY-output kinds), derived on read, never stored. Additive per §15.2. |
| 2026-09-06 | v1.11 | 078 calculator input ruling | A calculator context_key is an implicit optional execute input (owner ruling 2026-09-05): supplied in the execute request's parameters object → the node is skipped and the supplied value (coerced against the kind's output type; an ANY-output key takes any JSON scalar, refusal = pipeline.execution.invalid_parameter_type, §13.3) is what downstream nodes bind, marked provided_by: "caller" on the node's stats; unsupplied (JSON null included) → the node runs and computes it. §7.2's tier 4 widened accordingly (org < platform < parameters < execute-time inputs — now including calculator keys < calculator outputs). The parent→child mapping half of the ruling — a parent maps a calculator key into a child execution explicitly — ships as its own row with the composition change. Additive per §15.2. |
| 2026-09-06 | v1.10 | 078 contract gaps | §12.6's interpolation refusal set gains every calculator output key (a CALCULATOR node's context_key, typed by its kind's output type): ${calc_key} now fails save with template.validation.parameter_interpolated instead of the dry render's wrong code, the ${calc_key!} shape no longer slips past the scan, and a calculator key is refused in a conditional's test (<#if x??>, <#elseif x>) as well — a derived value gating SQL structure is the same hole. Additive per §15.2. |
| 2026-09-02 | v1.9 | 040 template used-by | §13.9 gains template.in_use (409) — template delete refused while any pipeline version pins any version of the template; the refusal carries the reverse scan's rows (pipeline, node, pipeline version, pinned version). Additive per §15.2. |
| 2026-09-02 | v1.8 | 046 typed templates | §12.6 gains pipeline.validation.template_type_mismatch — a DQL/DML/DDL node referencing a type='html' template is refused at pipeline save (template-hierarchy-design §7). §13.9 gains template.validation.type_invalid (unknown type wire value), template.validation.dialect_not_allowed (a dialect present on an html template) and template.validation.type_immutable (a payload attempting to change a template's type). Additive per §15.2. |
| 2026-09-01 | v1.7 | 037 agent data visibility | §13.4 gains pipeline.node.not_found (404) and pipeline.node.standalone_execution_refused (400) — the refusals of the pipelines_execute_node node-run debug query (MCP §6.2.20): an unknown node id, a tempdb source (staging exists only inside a full execution), or a PIPELINE node (it runs a child pipeline, not SQL). Node runs are agent debug queries, not executions — no history rows, no SSE, no idempotency (ratified, 037 §A). Additive per §15.2. |
| 2026-09-01 | v1.6 | 042 bound parameters | §7.4's example and prose move declared parameters to the :name bind form (Templates §4.5 — a declared parameter is a value; ${} is for structure). §12.6 gains the interpolation refusal: a declared name inside ${} fails save with template.validation.parameter_interpolated (§13.9). §13.4 gains pipeline.node.sql_parameter_missing — a :name the execution context does not declare fails loudly before anything executes. Additive per §15.2. |
| 2026-08-05 | v1.0 | initial draft | Initial pipeline contract: schema, nodes, parameters, validation, error codes, lifecycle |
| 2026-08-05 | v1.1 | review feedback | Replaced terminal_node_id with auto-detection (§9). Added type enum (DQL/DML/DDL) — §4.6, §8. Added output block with target (tempdb/caller/datasource) — §4.7, §8.1. Added settings.tempdb — §5. Renamed __staging__ → tempdb throughout. Removed datasources_used (redundant with node sources). Added engine field placeholder for templates. Rejected parallel_id — depends_on is mathematically complete for DAG parallelism (two nodes run in parallel iff neither is reachable from the other); a second parallelism source-of-truth would create reconciliation bugs. |
| 2026-08-07 | v1.2 | consistency campaign | D1: omitted output defaults to caller (was tempdb); topology-based terminal auto-detection replaced by caller-node resolution (§9); any DQL target combination legal — deleted dql_sink_missing_caller_target, no_dql_sink, disconnected_terminal, dql_missing_output; multiple_caller_targets → multiple_caller_nodes; zero caller nodes legal. D3: template variables validated by save-time dry-render (§7.4, §12.6); pipeline parameters is the single declaration point. D5: §13 expanded with §13.7–13.11 (auth normalized to 3-segment codes, datasource, template, result, rate-limit/idempotency) — the single concrete code catalog. §6.3 strict coercion rules; §10.1 per-namespace table uniqueness; §11.4 scan fields + heuristics specified; §15.4 version-counter disambiguation; §17.3 defers DDL to metadata-db. See SPEC-REVIEW-2026-08 |
| 2026-08-11 | v1.3 | gate C review | Additive §13 rows: template.not_found (404) and datasource.not_found (404) — read/mutate-path misses previously borrowed pipeline.validation.* codes, which are 400s for write-time validation. Adjudicated answer to the catalog gap flagged by mcp-server's McpNotFound and web's ApiErrors. |
| 2026-08-17 | v1.4 | pipeline composition | NodeType gains PIPELINE — a node that executes a version-pinned pipeline as a separate child execution and consumes its caller result (§4.9 node shape, §8.5 execution behavior). New §12.9 composition validations; §13.4 gains pipeline.node.child_execution_failed and pipeline.node.composition_depth_exceeded; §12.4 type_invalid enum widened. Additive per §15.2 (§15.1 records the additions). |
| 2026-08-27 | v1.5 | workspaces readonly slice | §12.5 gains pipeline.validation.datasource_readonly and §13.4 gains pipeline.node.datasource_readonly (workspaces design 2026-08-16 §6, D6/D10): a datasource flagged is_readonly forbids the three write-shaped uses — save-time (400, details triple of node id + datasource name + shape) and at execution (500, the live-registry per-node backstop covering the D10 flip window and composed children). Additive; DQL reads and everything tempdb untouched. |
| 2026-08-30 | v1.6 | local password auth | §13.7 gains three local-auth rows: auth.login.bad_credentials (401), auth.login.locked (403), auth.password.change_required (403) — defined in auth.md §9/§5A. The two existing auth.login.* descriptions reworded to name the method (OIDC or local). |
| 2026-08-30 | v1.7 | credential-minting is session-only | §13.7 gains auth.session.required (403): an API-key principal reached an operation that mints or rotates an interactive credential. Closes a post-merge escalation found by adversarial verification of 026 — an admin-scoped key could create a local admin, read its one-time password from the response, and sign in, yielding an unpinned session that outlived revocation of the key. Defined in auth.md §5A.7. |
| 2026-09-05 | v1.8 | pipeline folders (067) | §3.2 name becomes a folder path — character-for-character the template grammar (Template Hierarchy §4.1): 1–10 /-separated segments, each [a-z0-9][a-z0-9_.-]{0,63}, ≤ 200 chars. Folders are virtual (a name prefix, no table, no column, no id, no CRUD); workspaces stay the isolation boundary. §4.9's pipeline.name child reference takes the same grammar and reports name_invalid at nodes[i].pipeline.name when it does not. Node ids, output.table and parameter keys are NOT widened — §15.1 records exactly which naming rule moved. Relaxation per §15.2 with ONE narrowing: a name whose first character is _ was legal and is not. It needs no migration and no deploy gate, because a pipeline name is validated at SAVE only (pipelines are UUID-addressed and nothing on the execute path re-checks the grammar), so a legacy _scratch pipeline keeps listing, opening and executing and only its next save is refused — the difference from templates' §4.6, which had to abort a deployment because their grammar is re-checked at render time. No new error codes. |
| 2026-09-05 | v1.9 | published endpoints (074) | New §13.14 Published endpoints — the endpoint.* codes a publish, bind or serve can return (pipeline_not_readonly, path grammar/conflict, key-kind confinement, 202 on timeout). Codes only; the pipeline document itself is unchanged. |
| 2026-09-05 | v1.10 | mandatory folders (077) | §3.2 name requires a folder: the path is now 2–10 /-separated segments, not 1–10 (Template Hierarchy §4.1). A bare scratch is refused; test/scratch is the sanctioned scratch form. §13.9's pipeline.validation.name_invalid row is unchanged as a CODE — no new code — but its details gain reason: folder_required when a folder is the only thing missing, grammar otherwise, so an agent can tell the two refusals apart without parsing the message. A narrowing of the frozen contract, taken pre-release under the owner ruling of 2026-09-05 and for §4.5's reason: there is no rename, so a root-level name minted after the tag is permanent. Still no migration on the pipelines side — validated at SAVE only, so a legacy flat pipeline keeps listing, opening and executing and only its next save is refused (§14.2). |
| 2026-09-07 | v1.11 | dp-lake registry (089 §A) | §13.8 gains the lake-table codes: datasource.validation.lake_dialect_required (400 — a lake-table operation on a non-LAKE datasource), lake_namespace_invalid / lake_name_invalid (400 — the segment grammar), lake_format_invalid (400 — not parquet or iceberg), lake_location_invalid (400 — scheme allowlist plus the total injection refusal: locations are later interpolated into CREATE VIEW statements), lake_manifest_url_forbidden (400 — an import manifest URL outside the datasource's own endpoint/bucket; the SSRF boundary), datasource.lake_table_duplicate (409, mapped from metadata-db §4.15's named UNIQUE) and datasource.lake_table_not_found (404). Additive per §15.2. |