Contracts

Versioning: Draft, Release, Promotion

Status: v1.33 — #737: a parameter-set restore tells template_not_found from template_version_not_found by the template entity row, as a pipeline restore does since #733 (§3.5.3). v1.32 — #575: all five wire inventory arms require a RELEASED current pointer (§10.2). v1.31 — #733: a pipeline restore tells template_not_found from template_version_not_found by the template entity row, not a version-1 probe (§3.5.3). v1.30 — #517: a release takes its whole dependency closure, flips consented dependency drafts in one transaction with strict audit, and every pin-affecting write takes the workspace writer guard (§5.3, §3.5, §10.4); a pipeline or parameter-set restore re-judges its outbound pins (§3.5.3). v1.29 — #563: an object whose current pointer names a DRAFT is not promotable and is invisible to a promoter whatever releases it holds (§10.2, §3.4). v1.28 — #459: draft authoring may pin DRAFT or RELEASED children (D58, §3); #462: both pipeline draft purges refuse named parent/dashboard pins before destructive writes. #328: the release flip records the caller output (§5.3). #349: the UI workspace row states the in-page version switch (§7.2); #344: the push closure is read at the pinned versions without the promoter lens (§10.4); #332: the three families audit every lifecycle verb (§7's blanket sentence); #348: §7.2's table disambiguates the REST GET /api/v1/pipelines/{id} from the UI read workspace GET /pipelines/{id}; #10 L1c-c: the count ceiling stated as aggregate; the receive's refused-dashboard rollback witnessed (§10.4); #320: the reverse arrows into other families (§3.5.3); #10: visualizations and dashboards join the lifecycle table (§3.5) Owner: datapipelines.co core Depends on: Pipeline Contract (§13 error catalog, §17 persistence), Templates, Metadata DB (§4.4/§4.5/§4.8/§4.9 — DDL authority), REST API, Pipeline Editor UI Last updated: 2026-10-28


1. Purpose and Scope

This spec defines the version lifecycle for pipelines and templates: how edits become drafts, how a human releases (locks) a draft into an immutable version, and how released versions are later promoted to exactly one higher environment.

It decides:

  • The write rule: copy-on-write drafts living in the existing version tables — no separate draft tables.
  • The concurrency rule: content-hash preconditions on every mutation — no last-write-wins.
  • The release rule: an explicit, UI-only human action. Agents never release.
  • The identity rule: version numbers are global identities preserved across environments — imports never renumber.
  • The promotion rule: UI-driven, latest-released-only, guarded on both ends.

Everything here applies symmetrically to pipelines (pipeline_versions) and templates (template_versions) unless a section says otherwise.


2. Decision Log

Ratified 2026-08-31, operator + model session:

# Decision Rationale
D1 Drafts are rows in the existing version tables with a status column. No separate draft tables. One lifecycle, executions can reference drafts via the existing composite FK, and draft templates get real pre-allocated version numbers that draft pipelines can pin directly.
D2 The one write rule: writing to a RELEASED version first copies it to a draft (copy-on-write); writing to a DRAFT overwrites it in place. Bounded mutation — at most one mutable row per pipeline — instead of a version appended per save. Kills the version pile.
D3 Every mutation carries a content-hash precondition; mismatch is a 409. Two engineers (or an engineer and an agent, or two tabs) must never silently overwrite each other. No last-write-wins, ever.
D4 Release is an explicit UI action by a human. Agents/MCP always leave drafts. "Lock means release" is a human judgment made after testing.
D5 Version numbers are global identities. Import preserves the source version number; renumbering is forbidden. Cross-env renumbering breaks template pins silently (verified latent defect, §10.1) and confuses humans correlating envs.
D6 Promotion pushes only the latest RELEASED version per pipeline. The promotion UI lists only pipelines that are released and have a version number greater than the target's current version. Drafts and same-version re-pushes must never be offered; the server enforces the same rules independently (§11.3).
D7 No hotfixes. A bug fix is a new release in the dev environment, promoted like any other. Receiver environments never author. Local authoring on a receiver destroys number alignment (D5); the process rule keeps sequences globally coherent.
D8 Promotion is a separate, UI-driven use case against a single configured higher environment. Not an agent action; not automatic; not multi-hop.

Ratified 2026-09-08, operator ruling (implemented 099):

# Decision Rationale
D55 Creation lands version 1 DRAFT, for pipelines and templates alike, and current_version is NULL until a human releases. D4 holds without exception; only the non-authoring create paths (promotion import, the seeders that ride it) land RELEASED. "Contract says, pipeline will always be DRAFT and from DRAFT to RELEASED is a human step." §3.2's old RELEASED-on-create rule was an unratified assumption whose only justification — executability — has been false since 039 made drafts executable.
D56 Execute with no version runs the LAST version — the working version: the draft when one exists, else the latest release. On a development server that may be a draft; on a hardened (non-authoring) server no draft can exist, so it is always a release by construction. "We always run the LAST version of the pipeline if no version is specified. On Dev, last version can be RELEASED or even DRAFT. On servers which are not development, we will always have RELEASED pipelines. No DRAFT at all."

Ratified 2026-09-08, operator ruling (implemented 101):

# Decision Rationale
D57 A pipeline always has ≥ 1 version row (unless the entity itself is purged). Discard never removes rows — discarding the sole release is legal and lands the entity at DISCARDED with a NULL pointer. The refusal pipeline.version.last_release belongs to the PURGE path only: a RELEASED version is never purged, and an entity holding any non-draft version is never purged. "Delete means it's gone … discard is per version, the entity stays."
D58 Draft authoring may pin live DRAFT or RELEASED child versions (#459, owner requirement superseding the original released-only save rule). Release/import require RELEASED children (pipeline.validation.pipeline_reference_not_released). Template draft pins remain legal during development. Draft dependencies work throughout development; mutable children are revalidated at release.
D59 Names are unique forever. A discarded pipeline/template keeps its name; restore is refused only while an outbound pin is missing, discarded or not released (§3.5.3, #517) — repair bottom-up, never by editing the released body; no second entity may take the name. The partial-index idea is withdrawn; uq_pipelines_workspace_name / uq_templates_workspace_name stay as they are. A name is an identity other environments and histories reference; recycling it re-points them at a different thing.
D60 current_version is sticky and event-driven. It moves only on release, discard-of-current, restore-above-current, manual switch, and purge-of-current-draft; when it moves it picks the highest-numbered ELIGIBLE live version (RELEASED always; DRAFT only under development posture), else NULL. An import never moves an existing pointer — the human switches — except the first import of an entity with no current at all. "current_version helps us in fallback." The pointer is what dependents run; it must move deliberately, never as a side effect.
D63 A published endpoint serves whatever the pointer names in development, a draft included (owner 2026-09-09: "we have to test it before the release, so we should be able to point the API to any version in dev"). Non-development holds no drafts, so released-only there by construction. The endpoint is the thing under test; a 503 on the dev fallback made the testable path untestable.

3. Version Lifecycle

Rewritten 2026-09-08 (101, rulings D57–D60). This section replaces the former §3.1–§3.4 in full: the tombstone rule (an executed draft flipping to DISCARDED) and every sentence about deletion are withdrawn. Where prose elsewhere still repeats the old rule, §3.5's table is the ruling.

3.1 Statuses and verbs

Version statuses: DRAFT → RELEASED → DISCARDED.

Status Meaning Mutability
DRAFT The working copy. At most one per entity at any time (§3.3). Mutable in place (§5.2).
RELEASED The locked version. Immutable — the two status flips below touch status, the discard stamps and the pointer, nothing else.
DISCARDED A RELEASED version that was retired. Its row stays: executions, promotion history and parents reference it. Immutable.

Five verbs act on versions, and they pair with the statuses exactly:

  • Purge is the DRAFT verb (DELETE /pipelines/{id}/versions/{v} — drafts only). The row is hard-deleted together with its executions: development runs of a thing that never shipped are not history. Allocation always reads max(version) + 1, so a purged draft's number is free again exactly when nothing surviving outranks it (its executions went with it, so no dangling reference can exist). Purge is not reversible.
  • Discard is the RELEASED verb (POST …/versions/{v}/discard). The row flips to DISCARDED (discarded_at, discarded_by); no dependent may run it, and the row stays.
  • Restore is the DISCARDED verb (POST …/versions/{v}/restore): the row returns to RELEASED — its original released_at/released_by return, the discard stamps clear. Before it lands, the pins the version holds are rechecked under the workspace writer guard (§3.5.3, #517): a missing or DISCARDED pin refuses with the family's validation code (400), a present-but-DRAFT one <family>.release.dependency_not_released (409). Discard is reversible; purge is not.
  • Release is the DRAFT→RELEASED verb (§5.3, unchanged). Who may: an author or a workspace admin (roles design D8, ratified 2026-09-20: "the author releases, the promoter promotes"). Before R1 (#177) this was the promoter's; the promoter is now an ops role that authors nothing and sees, through the lens (§10.2), only what is promotable (Auth §11A).
  • Switch is the pointer verb (§3.4). Who may: an author or a workspace admin — the rollback lever stays with whoever owns the content; the promoter lost it with release.

Who may run the rest: discard, restore and purge are AUTHORING verbs — an author holds all three. That is safe because the system does not allow purging a release: purge is the DRAFT verb, and discard is reversible. Promotion is the promoter's (and the workspace admin's); release is not.

A DRAFT is never discarded and a RELEASED version is never purged: the verbs refuse with pipeline.version.not_released / template.version.not_released and pipeline.version.last_release / template.version.last_release (§13.13/§13.9) rather than guessing intent.

Which posture may run which verb. Discard, restore, purge and the entity purge are authoring writes — a promotion receiver (authoring-enabled=false) refuses them with pipeline.authoring.disabled / template.authoring.disabled, exactly like create, update, release and the draft verbs (§5.5). Two verbs are deliberately NOT authoring: switch (it is the promotion receiver's verb — pointing a receiver at the release it just imported, or back at the one before it, is the human's rollout and rollback lever) and import (unchanged since 039).

3.2 Entity status is derived; names are unique forever

An entity (pipelines / templates row) has no stored lifecycle status. It is derived, never stored:

  • ACTIVE while any of its versions is DRAFT or RELEASED;
  • DISCARDED when every one of its versions is DISCARDED.

There is no "discard the entity" verb: discarding its last live release IS the entity discard — the derivation lands there on its own. Purging the entity is allowed only when its only version is a DRAFT; the entity row goes with it, in the same transaction, together with the draft's executions. An entity therefore always has ≥ 1 version row unless the entity itself is gone (D57).

Names are unique forever (D59). A discarded pipeline or template keeps its name; restore is refused only while an outbound pin is missing, discarded or not released (§3.5.3, #517) — repair bottom-up, never by editing the released body; no second entity may ever take the name. The V1 is_deleted soft-delete column on both tables is retired (V19): any is_deleted = true row is migrated to "every version DISCARDED" and the column dropped; every reader of is_deleted reads the derived entity status instead. pipeline.validation.pipeline_reference_deleted becomes the derived "the referenced entity is DISCARDED" refusal — it blocks NEW references at save time; existing pinned references keep resolving, exactly as before.

3.3 One draft per entity; number allocation

A partial unique index enforces at most one DRAFT row per pipeline (and per template):

CREATE UNIQUE INDEX uq_pipeline_versions_one_draft
    ON pipeline_versions (pipeline_id) WHERE status = 'DRAFT';

This is what makes the write rule race-safe: two simultaneous first-writers both see "released", both attempt the draft insert — one wins; the loser's insert violates a unique constraint and surfaces as pipeline.version.conflict pointing at the new draft's hash (§4). The loser must re-read and rebase. Which constraint (#276): both writers allocate the same max + 1 below from the same committed rows, so the loser normally collides on the version table's PRIMARY KEY ((pipeline_id, version) / (template_id, version)) before it reaches the one-draft index — Postgres checks unique indexes in creation order, and the key is the older. Every repository that writes a draft (pipelines, templates, parameter sets) maps BOTH to its …version.conflict; mapping the index alone answers the race with a raw driver error (measured for templates by a forced race: TemplateRepositoryIntegrationTest).

Number allocation. A draft pre-allocates max(existing version) + 1 — never current_version + 1, because the pointer can be NULL (D55) and a DISCARDED row keeps its number consumed. The number is stable across all in-place writes to that draft. Numbers held by surviving rows are never reused; a purged draft's number is re-derived from the MAX over what survives (§3.1) — safe precisely because purge deletes the executions with the row.

The draft is allocated the highest number — and an import may land above it. Allocation always reads the MAX (§3.3), so a draft is created above every existing version; the one event that can place a version above a draft afterwards is an IMPORT onto an entity that holds one (§3.5's {D} first-import row lands 1D 2R cur=2): the imported release numbers past the draft, and the pointer names it. The true invariants are the allocation rule and "at most one draft" — the model test asserts both after every event, and NOT a draft-dominance that the table itself overrides.

3.4 current_version is sticky and event-driven (D60)

current_version is the version every pointer-following dependent runs — published endpoints, promotion, schedules (#9 — a schedule's version: "current" follows it, drafts included), dashboards later. It is sticky: it no longer means "the latest RELEASED version" as a derived fact. It moves ONLY on these events, and when it moves it picks the highest-numbered ELIGIBLE live version:

Event Pointer rule
release(v) current = v
discard(x) x ≠ current: unchanged. x = current: highest eligible live version, else NULL
restore(x) current = x only if x > current or current is NULL; otherwise unchanged
switch(v) — manual current = v; v must be live and eligible. The promotion receiver's verb (§3.1)
purge(draft v) as discard: only moves if v = current (a development fallback), then highest eligible live version, else NULL
import never moves an existing pointer — the human switches. Exception: the FIRST import of an entity that has no current at all sets it — nothing exists to break

Eligible = RELEASED, plus DRAFT under posture=development (authoring-enabled=true). Under any other posture only RELEASED is eligible — there are no drafts there by construction (§5.5), but the rule is stated, not assumed. A draft's mere EXISTENCE never moves the pointer; a draft is chosen only when the pointer must move and the draft is the highest live version (the discard/purge fallback above). A pointer that NAMES a draft has one promotion consequence: while it stays there the object is not promotable and is invisible to a promoter whatever releases it holds (§10.2, #563).

Dependents read the pointer; authors read "last" (D56, unchanged). Manual execute with no version is NOT a pointer read: it runs the working version — the draft when one exists, else the latest release — resolved in one place (PipelineService.workingVersion). The published surface serves the pointer when it names a RELEASED version, which under hardened postures is the only thing it can name.

NULL pointer. A published endpoint answers endpoint.pipeline_not_released at serve time — the endpoint row is not deleted, and an entity whose status is DISCARDED answers the same refusal rather than a 404; a schedule (#9) records a not_started run (reason pointer_null) and blocks until a person unblocks it (Scheduler §5); promotion has nothing to push.

A draft pointer (D63, 2026-09-09). Under the development posture the pointer may name a DRAFT (the D60 fallback, or a manual switch), and a published endpoint serves it — the endpoint has to be testable before the release, so in development the API can be pointed at any live version. Outside development no draft exists, so a release is served by construction; the serve path checks the same eligibility rule the pointer uses (PipelineVersionStatus.eligibleForPointer), never a status of its own.

3.5 The lifecycle table

The tree. Pipelines and templates are two folder trees — joined by #194 lane D's third, parameter sets (the record's §8.1: the templates' table shape, the pipelines' hash and index semantics, addressed by id). Every lifecycle row below reads with parameter.* codes for the set family; a set releases only when every pinned template version is RELEASED (or the 142 cascade with consent), and release re-runs the record's §4 source validation against the pins as they are now. #10 adds the fourth and fifth families — visualizations and dashboards (Dashboards §3, V42: the parameter-set shape twice) — and every row reads with visualization.* / dashboard.* codes for them. A visualization releases only with at least one test case, its transform pin RELEASED (or the 142 cascade with consent) and the D56 evidence gate passing (the tests lane's; until it is installed every visualization release is refused); a DRAFT visualization version pinned by a LIVE dashboard version is never purged, nor a pinned release discarded (visualization.version.pinned — graph rule 1). A dashboard releases only when its set is RELEASED, its sources RELEASED and read-only, and every pinned visualization RELEASED — or released with it in one transaction under release_pinned_visualizations (D61). Every leaf has an ordered version list. Dependencies are edges: exact-version pins (a pipeline node's template pin, a PIPELINE node's child pin) and pointer edges (endpoint→current, schedule→current). Three graph rules cover every refusal:

  1. A version with an inbound exact pin from a LIVE (non-discarded) version cannot be discarded or purged — the refusal names the pinning entities. Codes: pipeline.version.pinned for child pipelines and, since #320, for the dashboards whose sources pin the version (details.referencing_dashboards, beside pinned_by); for templates the existing template.in_use IS the in-use refusal — no template.version.pinned is added — and it names pipelines (pinned_by), parameter sets (referencing_parameter_sets) and, since #320, visualizations whose transform.template pins the version (referencing_visualizations); parameter.in_use (#320) for a parameter set a dashboard pins; visualization.version.pinned for a visualization a dashboard pins. Every arrow, verb and details key is §3.5.3.
  2. A pointer edge follows the pointer and refuses on NULL (§3.4).
  3. An entity can be purged only when it has no inbound edges of any kind and its only version is a DRAFT. For pipelines both draft purge entry points and the explicit entity purge ask the existing entity guard: live parent pipeline versions pinning the exact draft, plus every stored dashboard version of a dashboard that still has a live version, including DISCARDED versions, pinning any version of that pipeline. There is no historical parent-pipeline scan. A never-released pipeline cannot be published, but since #459 draft parents and dashboard sources may pin it. On the template side this is the template.in_use check. A draft purge that takes the entity with it (the draft is the only version) is an entity purge and asks that entity question; a draft purge that keeps other versions asks rule 1's live exact-pin question of the draft's own version. Both pipeline draft purges refuse pipeline.version.pinned with pinned_by and referencing_dashboards before deleting executions or other rows (#462).

Composition (D58, #459). A draft pipeline may pin a live DRAFT or RELEASED child version at save; a DISCARDED or missing child is refused. Release and import require every pinned child RELEASED (pipeline.validation.pipeline_reference_not_released), so released composition still references only reviewed content. Template pins keep their rule: a DRAFT pipeline may pin a DRAFT or RELEASED template version while iterating, and release requires every pinned template version RELEASED (§5.3). Pinning a DISCARDED template version is refused at save (pipeline.validation.template_version_not_found) — which is what makes "no live version pins a DISCARDED version" an invariant rather than a hope.

The sole version (D57). Discarding the only RELEASED version is legal — the entity becomes DISCARDED and the pointer goes NULL; restore brings it back. The refusal pipeline.version.last_release / template.version.last_release belongs to the PURGE path only: "discard is per version, the entity stays; restore or release something first."

Purging a draft pipeline offers its exclusive draft templates. The purge call accepts include_exclusive_draft_templates: true: the service computes the set of DRAFT template versions the draft body pins that no OTHER stored pipeline version pins — DRAFT, RELEASED or DISCARDED alike (R12, 2026-09-13: a discarded version can be restored and must keep what it runs; a purged version has no row and cannot pin) — and either purges them with the pipeline (true) or merely returns them in the response (false, the default) — either way the caller sees the set, so the UI (102's entity-purge dialog, §4.3d) offers the cleanup as a checkbox and the default is the same offer the response carries. Since #320 "no OTHER pinner" spans every aggregate that pins templates: a DRAFT-only template that a parameter set or a visualization also pins (any version, any status) is never offered and never purged — the pipeline purge proceeds, and the response's kept_draft_templates names each template it kept and who else pins it (referencing_parameter_sets, referencing_visualizations, the keys template.in_use uses). The offered purge re-checks inside the purge's transaction and refuses template.in_use — rolling the whole purge back — if such a pin appeared since.

3.5.1 Notation and blanket rules

Rows below use: 1R 2X 3D cur=1 — version number with status letter (R released, X discarded, D draft), cur the pointer (∅ = NULL). The Shape column groups rows into the ten families {D} {R} {R,D} {R,R} {R,R,D} {R,X} {X,D} {X,X} {X,X,D} {R,X,D}; within a family the order of statuses matters for pointer outcomes, so every row states its full Before map. Codes shown are the pipeline spelling; the template twins substitute template.version.last_release / not_released / not_discarded / not_eligible and template.in_use (for pinned).

  • Posture blanket rule. Discard, restore, purge and purge(entity) are authoring writes: under hardened posture every such row — whatever its shape — is refused pipeline.authoring.disabled / template.authoring.disabled before any other check. The rows below give the development outcome for those verbs, and carry a posture cell only where a non-authoring verb (switch, import) differs by posture: dev = development; hard = hardened; both = identical under either. Draft-holding shapes cannot arise under hardened (§5.5); their rows state the rule.
  • Missing versions. An event naming a version the entity does not hold is 404 pipeline.execution.not_found (template.not_found for templates) — uniformly, not per-row.
  • The pointer-followers. After every allowed row, cur names a live eligible version or is NULL; dependents behave per §3.4. This is the invariant the model test asserts after every event — the table never restates it per row.

3.5.2 The table

Shape Before Event Posture Inbound edge Outcome After Entity
{D} 1D cur=∅ release dev — allowed 1R cur=1 ACTIVE
{D} 1D cur=∅ purge(v1) dev — allowed — row + executions deleted; sole version ⇒ entity purge entity gone gone
{D} 1D cur=∅ purge(entity) dev — allowed — same outcome as purge(v1) entity gone gone
{D} 1D cur=∅ purge(v1) dev live parent version pins v1 refused pipeline.version.pinned 1D cur=∅ ACTIVE
{D} 1D cur=∅ purge(entity) dev live parent version pins v1 refused pipeline.version.pinned 1D cur=∅ ACTIVE
{D} 1D cur=∅ purge(entity) dev draft template pinned by a stored pipeline, parameter set or visualization version, discarded included (template twin; the draft purge that takes the entity asks the same) refused template.in_use 1D cur=∅ ACTIVE
{D} 1D cur=∅ discard(v1) dev — refused pipeline.version.not_released (drafts are purged, never discarded) 1D cur=∅ ACTIVE
{D} 1D cur=∅ switch(v1) dev — allowed — a draft is eligible in development 1D cur=1 ACTIVE
{D} 1D cur=∅ switch(v1) hard — refused pipeline.version.not_eligible 1D cur=∅ ACTIVE
{D} 1D cur=∅ import (first — no current) both — allowed — lands RELEASED at max+1 (version-less path) or the payload's exact number, and sets the pointer 1D 2R cur=2 ACTIVE
{R} 1R cur=1 release dev — refused pipeline.version.not_draft 1R cur=1 ACTIVE
{R} 1R cur=1 discard(v1) dev — allowed — no eligible live remains ⇒ pointer NULL 1X cur=∅ DISCARDED
{R} 1R cur=1 discard(v1) dev live parent version pins v1 refused pipeline.version.pinned 1R cur=1 ACTIVE
{R} 1R cur=1 purge(v1) dev — refused pipeline.version.last_release (a release is never purged) 1R cur=1 ACTIVE
{R} 1R cur=1 purge(entity) dev — refused pipeline.version.last_release 1R cur=1 ACTIVE
{R} 1R cur=1 restore(v1) dev — refused pipeline.version.not_discarded 1R cur=1 ACTIVE
{R} 1R cur=1 switch(v1) both — allowed (no-op: v is live and eligible) 1R cur=1 ACTIVE
{R} 1R cur=1 import (subsequent) both — allowed — lands RELEASED; the pointer does NOT move (D60) 1R 2R cur=1 ACTIVE
{R,D} 1R 2D cur=1 release dev — allowed 1R 2R cur=2 ACTIVE
{R,D} 1R 2D cur=1 release dev draft pins a DRAFT template version refused pipeline.release.dependency_not_released (§5.3's rule, restated) 1R 2D cur=1 ACTIVE
{R,D} 1R 2D cur=1 release(releasePinnedTemplates) dev draft pins a DRAFT template version (template twin: the pinned template releases with it) allowed — the pinned template version and the pipeline flip in ONE transaction, templates first (§5.3's cascade, 142); a refusal anywhere rolls both back 1R 2R cur=2 ACTIVE
{R,D} 1R 2D cur=1 purge(v2) dev — allowed — draft + its executions deleted 1R cur=1 ACTIVE
{R,D} 1R 2D cur=1 purge(v2) dev live parent version pins v2 refused pipeline.version.pinned 1R 2D cur=1 ACTIVE
{R,D} 1R 2D cur=1 purge(v2) dev draft template v2 pinned exactly by a LIVE pipeline, parameter set or visualization version (template twin) refused template.in_use 1R 2D cur=1 ACTIVE
{R,D} 1R 2D cur=1 discard(v1) dev — allowed — fallback: the draft is the highest eligible live version 1X 2D cur=2 ACTIVE
{R,D} 1R 2D cur=1 switch(v2) dev — allowed 1R 2D cur=2 ACTIVE
{R,D} 1R 2D cur=1 switch(v2) hard — refused pipeline.version.not_eligible 1R 2D cur=1 ACTIVE
{R,D} 1R 2D cur=1 import (subsequent) both — allowed — v₃ RELEASED; pointer stays 1R 2D 3R cur=1 ACTIVE
{R,R} 1R 2R cur=2 release dev — refused pipeline.version.not_draft 1R 2R cur=2 ACTIVE
{R,R} 1R 2R cur=2 discard(v2 = current) dev — allowed — fallback to v1 1R 2X cur=1 ACTIVE
{R,R} 1R 2R cur=2 discard(v1 ≠ current) dev — allowed — pointer untouched 1X 2R cur=2 ACTIVE
{R,R} 1R 2R cur=2 discard(v2) dev live parent version pins v2 refused pipeline.version.pinned 1R 2R cur=2 ACTIVE
{R,R} 1R 2R cur=2 purge(v1) / purge(v2) dev — refused pipeline.version.last_release 1R 2R cur=2 ACTIVE
{R,R} 1R 2R cur=2 purge(entity) dev — refused pipeline.version.last_release 1R 2R cur=2 ACTIVE
{R,R} 1R 2R cur=2 switch(v1) both — allowed — switching DOWN to an older release is legal (the receiver's rollback lever) 1R 2R cur=1 ACTIVE
{R,R} 1R 2R cur=2 import (subsequent) both — allowed — v₃ RELEASED; pointer stays; the human switches 1R 2R 3R cur=2 ACTIVE
{R,R,D} 1R 2R 3D cur=2 release dev — allowed 1R 2R 3R cur=3 ACTIVE
{R,R,D} 1R 2R 3D cur=2 discard(v2 = current) dev — allowed — fallback catches the draft 1R 2X 3D cur=3 ACTIVE
{R,R,D} 1R 2R 3D cur=2 discard(v1) dev — allowed — pointer untouched 1X 2R 3D cur=2 ACTIVE
{R,R,D} 1R 2R 3D cur=2 purge(v3) dev — allowed 1R 2R cur=2 ACTIVE
{R,R,D} 1R 2R 3D cur=2 switch(v3) dev — allowed 1R 2R 3D cur=3 ACTIVE
{R,R,D} 1R 2R 3D cur=2 switch(v3) hard — refused pipeline.version.not_eligible 1R 2R 3D cur=2 ACTIVE
{R,X} 1R 2X cur=1 restore(v2) dev — allowed — v2 > current ⇒ pointer moves 1R 2R cur=2 ACTIVE
{R,X} 1R 2X cur=1 discard(v1 = current) dev — allowed — no eligible live remains ⇒ NULL 1X 2X cur=∅ DISCARDED
{R,X} 1R 2X cur=1 switch(v2) both — refused pipeline.version.not_eligible (discarded is not live) 1R 2X cur=1 ACTIVE
{R,X} 1R 2X cur=1 import (subsequent) both — allowed — v₃ RELEASED; pointer stays 1R 2X 3R cur=1 ACTIVE
{R,X} 1R 2X cur=1 purge(v1) / purge(entity) dev — refused pipeline.version.last_release 1R 2X cur=1 ACTIVE
{R,X} 1X 2R cur=2 restore(v1) dev — allowed — v1 < current ⇒ pointer untouched 1R 2R cur=2 ACTIVE
{R,X} 1X 2R cur=2 discard(v2 = current) dev — allowed — no eligible live remains ⇒ NULL 1X 2X cur=∅ DISCARDED
{X,D} 1X 2D cur=2 release dev — allowed 1X 2R cur=2 ACTIVE
{X,D} 1X 2D cur=2 purge(v2 = current) dev — allowed — purge-of-current falls back; nothing eligible remains ⇒ NULL 1X cur=∅ DISCARDED
{X,D} 1X 2D cur=2 restore(v1) dev — allowed — v1 < current ⇒ pointer untouched 1R 2D cur=2 ACTIVE
{X,D} 1X 2D cur=2 purge(entity) dev — refused pipeline.version.last_release (holds a non-draft row) 1X 2D cur=2 ACTIVE
{X,D} 1X 2D cur=2 switch(v1) dev — refused pipeline.version.not_eligible (switch(v2) is the allowed no-op) 1X 2D cur=2 ACTIVE
{X,X} 1X 2X cur=∅ restore(v2) dev — allowed — current NULL ⇒ pointer set 1X 2R cur=2 ACTIVE
{X,X} 1X 2X cur=∅ restore(v1) dev — allowed — current NULL ⇒ pointer set 1R 2X cur=1 ACTIVE
{X,X} 1X 2R cur=2 (after restore(v2)) restore(v1) dev — allowed — v1 < current ⇒ pointer untouched 1R 2R cur=2 ACTIVE
{X,X} 1X 2X cur=∅ purge(entity) dev — refused pipeline.version.last_release 1X 2X cur=∅ DISCARDED
{X,X} 1X 2X cur=∅ release / switch dev — refused pipeline.version.not_draft / pipeline.version.not_eligible — nothing live (hardened: release is the blanket authoring refusal) 1X 2X cur=∅ DISCARDED
{X,X} 1X 2X cur=∅ import (first — no current) both — allowed — v₃ RELEASED; pointer set; the entity is ACTIVE again 1X 2X 3R cur=3 ACTIVE
{X,X,D} 1X 2X 3D cur=3 release dev — allowed 1X 2X 3R cur=3 ACTIVE
{X,X,D} 1X 2X 3D cur=3 purge(v3 = current) dev — allowed — fallback; nothing eligible remains ⇒ NULL 1X 2X cur=∅ DISCARDED
{X,X,D} 1X 2X 3D cur=3 restore(v2) dev — allowed — v2 < current ⇒ pointer untouched 1X 2R 3D cur=3 ACTIVE
{X,X,D} 1X 2X 3D cur=3 purge(entity) dev — refused pipeline.version.last_release 1X 2X 3D cur=3 ACTIVE
{R,X,D} 1R 2X 3D cur=1 release dev — allowed 1R 2X 3R cur=3 ACTIVE
{R,X,D} 1R 2X 3D cur=1 discard(v1 = current) dev — allowed — fallback catches the draft 1X 2X 3D cur=3 ACTIVE
{R,X,D} 1R 2X 3D cur=1 purge(v3) dev — allowed — v3 ≠ current ⇒ pointer untouched 1R 2X cur=1 ACTIVE
{R,X,D} 1R 2X 3D cur=1 restore(v2) dev — allowed — v2 > current ⇒ pointer moves 1R 2R 3D cur=2 ACTIVE
{R,X,D} 1R 2X 3D cur=3 purge(v3 = current) dev — allowed — fallback to v1 1R 2X cur=1 ACTIVE
{R,X,D} 1R 2X 3D cur=3 discard(v1 ≠ current) dev — allowed — pointer untouched 1X 2X 3D cur=3 ACTIVE
{R,X,D} 1R 2X 3D cur=3 restore(v2) dev — allowed — v2 < current ⇒ pointer untouched 1R 2R 3D cur=3 ACTIVE

VersioningSpecDriftTest parses this table (the ScopeMatrixSpecDriftTest shape), so the model test in §13 runs the DOC's rows, not a copy. Editing a row is editing the rule; the parser fails the build on a row it cannot read.

Release across entities, and the twins (#517). The table models ONE entity, so a release that flips dependencies appears only as the release(releasePinnedTemplates) row — the legacy alias, its template twin releasing with it. A release plan (§5.3: tree_hash, include_dependencies) is the same transition applied to every selected DRAFT in the closure, dependency-first, in one transaction: each selected node takes its own release row's outcome, and any refused row refuses the whole release. The template twin's release row runs in one metadata transaction under the workspace writer guard since #517 (it was a bare statement before). Every other event in the table — and both import rows, first and subsequent — is a write that changes what a release resolves, so it too runs under the writer guard; a guard wait past lock-wait-seconds refuses the row with pipeline.release.busy / template.release.busy, nothing written. The table's outcomes are otherwise unchanged.

3.5.3 The reverse arrows into other families (#320)

A visualization and a dashboard pin other artifacts by name and exact version (the design record §4.2: "deletion and purge guards keep referenced releases"). The scans live over visualization_versions and dashboard_versions (ArtifactDependents, workspace-scoped, one bound JSONB probe per arrow); the owning modules reach them through a port application implements — no module's SQL names another module's tables.

Arrow (dependent → pinned) Refused verbs Code Details naming the dependents
visualization transform.template → template draft purge, version purge, discard, entity purge, the exclusive-draft offer template.in_use referencing_visualizations (names), beside pinned_by and referencing_parameter_sets
dashboard sources[].pipeline → pipeline version draft purge, version purge, discard, entity purge pipeline.version.pinned referencing_dashboards: [{dashboard, version, status}], beside pinned_by
dashboard parameter_set → parameter-set release draft purge, version purge, discard, entity purge parameter.in_use pinned_by: [{dashboard, version, status}], id, version (absent for an entity purge)
dashboard visualizations[].visualization → visualization draft purge, version purge, discard, entity purge visualization.version.pinned (L1a) pinned_by: ["dashboard@version"]

Endpoints and schedules pin nothing (pointer edges, rule 2): no arm exists for them.

Which question each verb asks. A discard and a draft purge that keeps other versions ask the LIVE exact-pin question (rule 1: DRAFT or RELEASED dependent versions pinning exactly this version). An entity purge, and a draft purge that takes the entity, ask the ANY-version question (rule 3, R12: every stored dependent version — DISCARDED included — of a dependent that still has a live version, because a restore would resurrect the pin). The two questions share one statement shape and differ only in their scope predicate.

Pipeline draft pins are reachable (#459, #462). Draft parents and draft dashboards may save pins to a DRAFT pipeline. A sole-draft purge therefore asks the entity guard before any deletion; a nonsole purge asks live exact pins. Dashboard history can still contain a draft pin beside a repointed live version, so the sole-draft dashboard scan includes that stored history. Parent pipeline pins remain live/exact in both modes; this does not add a historical parent scan. A RELEASED dependent still requires RELEASED dependencies (or a consented cascade at release), and releases never return to DRAFT or become purgeable. Release/import strictness is unchanged. The template and parameter-set entity guards retain their any-version scans; the visualization entity guard counts LIVE dashboard versions only.

Restore re-judges dependencies (D7). A DISCARDED version protects nothing, so the version it pins can be discarded while it is discarded. dashboards' and visualizations' restore therefore re-runs the dependency rules against today's state and refuses dashboard.validation.dependency_not_found / visualization.validation.transform_binding_invalid (reason: template_not_found or template_version_not_found), naming the dead pin — only the dependency failures; a rule that tightened since is not a dangling pin.

Pipelines and parameter sets do the same since #517 (release-dependencies record §12.1, ruled 2026-10-25), under the workspace writer guard and from metadata alone (no render, no check gate, no selector dry run, no customer connection). A missing or DISCARDED pin refuses with the family's validation code (400): a template pin template_not_found / template_version_not_found (reason: discarded when the version is DISCARDED; for a pipeline's template pin and a parameter set's selector pin alike, the template ENTITY row of the workspace tells a missing template from a missing version — a template can lose version 1 to a preserved-number import or a draft purge, so no version number stands in for the entity, #733, #737), a pipeline's child pin pipeline_not_found / pipeline_version_not_found / pipeline_reference_not_released (status: DISCARDED). A present-but-DRAFT pin refuses <family>.release.dependency_not_released (409, the import-landing precedent, reason: not_released in the shared blockers shape); no save rule lets a once-released body pin a draft, so that branch is defensive. The dashboard and visualization restores have no such branch: they refuse the missing pin only and admit a present-but-DRAFT one. The 400 answers first. Repair is bottom-up: restore the dependency, then the dependent. A refusal writes nothing — status, pointer and audit stand.

The lens. templates_used_by and the template screen narrow the visualization arm like the others: a hidden visualization is never named, and a narrowing view reports RELEASED rows only. The refusals need no lens: every verb above is held by author, workspace admin and super admin (and their MCP keys) — a promoter holds none of them (auth §7.6), so no lensed caller can reach a refusal. templates_purge_draft still narrows its echo (a name never, a count never — only pins_hidden: true).

The residual window (the C32 shape). The dependents are read by the service before the write, not inside the statement: a dashboard RELEASE that races a pipeline DISCARD can leave a released dashboard pinning a discarded release. The runtime then refuses to serve it (dashboard.runtime.dependency_missing), and a re-run of validate names the dead pin. The pipeline draft purge preflight also retains a concurrent pin/write window: a draft reference saved after the scan can race the purge. This service check is not a concurrency-proof referential constraint; if such a race removes the entity, later name reuse can still re-resolve a dangling draft pin.

3.6 The one write rule (copy-on-write)

When we write, we make sure it is not a released version. If it is released, we first create a draft out of the released version, then write. Otherwise we write — no problem.

Mechanically, PUT /pipelines/{id} resolves the current state of the pipeline and takes exactly one of two branches (§5.1/§5.2). There is no third branch: a PUT never appends a released version, and a PUT never touches a RELEASED or DISCARDED row.

Creation is draft-first too, without exception (D55). There is nothing to copy from, so there is no copy-on-write step — but POST /pipelines, pipelines_create, POST /templates and templates_create all land version 1 as DRAFT, and the index row's current_version stays NULL until a human releases it. DRAFT → RELEASED is a human step; D4 holds with no exception, on creation as on every subsequent change.

The pipeline is executable the moment it is created all the same: drafts have been executable since 039 (§8), and execute with no version runs the working version (§7.2). That is what retired the previous rule. Until 2026-09-08 this section said creation "lands version 1 directly as RELEASED, so an MCP-authored pipeline is executable the moment it is created" — an assumption written on 2026-08-31, never ratified, whose only justification stopped being true when drafts became executable. Its cost was real: an agent that created demo/top_carrier_by_borough followed it to the letter and produced a RELEASED pipeline no human had looked at.

The two create paths that are not authoring keep landing RELEASED, and that is the whole exception: a promotion import (§9.2) and the seeders that ride the same import services (ExampleContentSeeder, LakeBootstrapSeeder). The content they write was reviewed and released where it came from, and the system actor is not an agent asking for a review. The distinction is a required argument on both repositories' create (CreateLifecycle), so a new create path has to say which of the two it is and a missed one is a compile error.

3.7 What rides the draft vs. the release

name, display_name and description exist in two places, and that is deliberate — they are not two copies of one fact:

  • pipeline_versions.body_json is the artifact. The contract requires those fields (§3.1) and environment-portability requires the body to stand alone (§2 principle 1): an export bundle that did not describe its own pipeline could not be imported.
  • The pipelines row is an INDEX over the artifact. UNIQUE (name) needs a real column; the list screens' search filters PipelineRecord.name/displayName/description directly; every join reads the row without parsing JSONB.

Ratified 2026-08-31 — the row indexes the CURRENT RELEASED body. Metadata edits ride the release: a draft carries its metadata in its own version row, and the pipelines row keeps the released values until lock. This is not a preference between two workable options. Populating the row from a draft would make it an index over a mixture — some rows describing released content, some describing unreleased edits, with nothing in the schema marking which — and every consumer of the row (search, list, name lookup) would silently inherit that ambiguity.

The workspace header shows a draft's pending-release affordance when the caller's view admits one (the Release / Purge-draft verbs are the lifecycle dialogs', role-gated); the read page's version chip names the version it RESOLVED — draft or release — and the list shows the released name until lock.

What surface the write arrived on (102, owner ruling 2026-09-09). Every version row on both tables carries created_via / updated_via — 'session' | 'api_key' | 'mcp', stamped at the ENTRY POINT (the REST controllers map the principal's auth method; the MCP tools pass mcp; import and seed paths keep the 'session' default) — while created_by / updated_by keep naming the PERSON on every path, key principals included (a key's writes are its owner's): no key id is ever stored on a row. A release stamps nothing new — D4 already says a release is a human in a session, which is asserted, not recorded.

Draft-write-time uniqueness check (ratified, and the reason this is safe). Because UNIQUE (name) lives on the row and the row is not updated until release, a draft rename to a taken name would otherwise fail only at Release — after the work is done. So a draft write that changes name validates it immediately:

SELECT 1 FROM pipelines
 WHERE name = :newName AND workspace_id = :workspaceId AND id <> :thisPipelineId;

A hit is pipeline.validation.duplicate_name (409 — the existing code, no catalogue addition) at draft-write time. Uniqueness is checked AGAINST the index without being enforced FROM it, so the early failure costs nothing structurally. The release-time constraint remains as the backstop: it is the authority, this is the courtesy.


4. Content Hash (body_hash)

4.1 Definition

body_hash = SHA-256 (hex) of the canonical body JSON, stored on every version row at write time. Canonicalization is frozen — the hash is only as good as the canonical form — and v1.3 pins it mechanically:

  • Pipelines: the canonical form is the database's JSONB text projection of the body — encode(sha256(convert_to(body_json::text, 'UTF8')), 'hex'), computed BY THE DATABASE in V6's backfill and in every repository write, one expression everywhere. The serializer's output remains the write FORMAT, but a JSONB column does not preserve the writer's key order, so hashing the serializer string in the application while the backfill hashed the stored projection would give two hashes for one body — exactly the "every pre-existing row fails its first precondition check" failure §11's backfill option exists to avoid. Database computation is also what makes §11's own "pgcrypto digest" backfill option sound.
  • Templates: the canonical form is the version-owned field object {engine, dialect, is_library, imports, body} projected through jsonb_build_object(…) (which normalizes key order deterministically) and hashed with the same expression. display_name/description are NOT in it — see §6's asymmetry note.

The stored hash doubles as:

  1. The precondition token for every mutation (§4.2).
  2. Cross-server content identity for promotion delta detection (§11.2) — same content hash on two servers ⇒ same body, regardless of environment.
  3. A cheap stored-integrity check (recompute vs. stored, e.g. in audits).

4.2 The precondition protocol

Every mutation — draft create, draft write, release, discard — carries the hash of the version the caller based its change on (an If-Match-style request header or an explicit expected_hash field; the REST contract fixes the exact spelling at implementation). The server evaluates the precondition inside the mutating statement's WHERE clause:

  • Draft write / release / discard: expected hash must equal the DRAFT row's stored body_hash.
  • Draft create (first write after release): expected hash must equal the RELEASED row's stored body_hash.

Zero rows affected ⇒ the base is stale ⇒ 409 pipeline.version.conflict (or template.version.conflict) with the current state in details:

{
  "code": "pipeline.version.conflict",
  "message": "Pipeline was modified by someone else after you loaded it.",
  "details": {
    "current_body_hash": "…",
    "current_status": "DRAFT",
    "updated_by": "user-id",
    "updated_at": "2026-08-31T14:03:11Z"
  }
}

The client's recovery path is explicit: reload, diff against its own edit, re-apply. The server never merges and never overwrites. This protects every pairing — two engineers, engineer + MCP agent, two tabs.

4.3 What the hash does NOT do

It is not a diff format, not a merge token, and not a security signature (it is not keyed). Its only job is equality: is the thing I am about to modify the thing I looked at?


5. Write Paths

All four paths stay single-statement data-modifying CTEs — the repository house pattern (PipelineRepository KDoc) — so each remains atomic without an enclosing transaction. Sketches; the implementation owns final SQL.

5.1 Draft create (copy-on-write, first write after release)

WITH guard AS (                       -- precondition: caller's base is the released body
    SELECT 1 FROM pipeline_versions
     WHERE pipeline_id = :id AND version = :currentVersion
       AND status = 'RELEASED' AND body_hash = :expectedHash
), draft AS (
    INSERT INTO pipeline_versions
        (pipeline_id, version, body_json, body_hash, status, created_by, updated_by, updated_at)
    SELECT pipeline_id, version + 1, CAST(:bodyJson AS jsonb), :bodyHash, 'DRAFT', :actor, :actor, NOW()
      FROM pipeline_versions
     WHERE pipeline_id = :id AND version = :currentVersion AND status = 'RELEASED'
       AND <the incoming body's hash expression> <> v.body_hash   -- the no-op guard (below)
       AND NOT EXISTS (SELECT 1 FROM pipeline_versions
                       WHERE pipeline_id = :id AND status = 'DRAFT')
    RETURNING pipeline_id, version
)
SELECT * FROM draft, guard            -- guard empty ⇒ 0 rows ⇒ 409

The no-op guard (v1.5): a PUT whose body is identical to the released one must not create a draft, burn a version number, or light the pending-release badge for a change nobody made. The predicate compares the incoming body's hash to the released row's stored hash, in the same statement, by the same canonical-hash expression the INSERT would store — never in the application, where a second implementation of the canonical form is exactly the defect 035 found live. The statement's no-op arm returns the current RELEASED detail in that case, so the response for a no-op shows the current state — status: RELEASED, no draft — rather than making the caller infer it from an absence. It is not a 4xx: the write was well-formed and the outcome is "already in that state". Consequences the implementation pins:

  • Both the draft arm and the no-op arm join the guard, so a stale precondition still yields zero rows ⇒ 409 — D3's no-last-write-wins outranks tidiness.
  • The no-op arm requires that no draft exists: identical content written while a draft is open is a stale base (409 carrying the draft's state), never a "no draft" answer that would contradict the working state.
  • A draft edited back to match its released parent is LEFT ALONE (written in place, never auto-discarded): silently deleting a draft row, its version number and its updated_by history because someone reverted would be surprising, and discard stays explicit.
  • Templates mirror all of this (§6), with the documented asymmetry that display_name/description are not part of the hashed artifact — a content-identical save that changes only index metadata still moves the metadata, without opening a draft.

Why this matters beyond tidiness: a draft exists if and only if the content genuinely differs. Draft-existence is therefore a truthful signal — which is what §7's authoring reads rely on and what two people editing one pipeline need.

5.2 Draft write (in-place)

UPDATE pipeline_versions
   SET body_json = CAST(:bodyJson AS jsonb), body_hash = :bodyHash,
       updated_by = :actor, updated_at = NOW()
 WHERE pipeline_id = :id AND status = 'DRAFT' AND body_hash = :expectedHash
RETURNING version                     -- 0 rows ⇒ 409 (stale base or no draft)

A PUT arriving while no draft exists takes §5.1; a PUT arriving while a draft exists takes §5.2. The branch is decided by the draft's existence, and both branches carry the same precondition semantics.

5.3 Release (lock)

One statement, three effects — validate happens in the service layer immediately before:

WITH locked AS (
    UPDATE pipeline_versions
       SET status = 'RELEASED', released_at = NOW(), released_by = :actor,
           caller_output_json = (              -- #328: the D1 record, in the SAME statement
               SELECT e.result_schema_json
                 FROM pipeline_executions e
                WHERE e.pipeline_id = :id
                  AND e.pipeline_version = pipeline_versions.version
                  AND e.status = 'SUCCESS' AND e.parent_execution_id IS NULL
                  AND e.result_schema_json IS NOT NULL
                  AND e.started_at > COALESCE(pipeline_versions.updated_at,
                                              pipeline_versions.created_at)
                ORDER BY e.started_at DESC LIMIT 1)
     WHERE pipeline_id = :id AND status = 'DRAFT' AND body_hash = :expectedHash
    RETURNING version
), bumped AS (
    UPDATE pipelines
       SET current_version = (SELECT version FROM locked),
           name = :name, display_name = :displayName, description = :description,
           updated_at = NOW()
     WHERE id = :id
    RETURNING current_version
)
SELECT * FROM locked, bumped          -- 0 rows ⇒ 409

(v1.3 dropped the sketch's updated_at = NOW() from the flip: §11's column note makes updated_at draft-write metadata — a release or discard does not restamp it, so a released row keeps the timestamp of its last draft write. The two sections now agree.)

(#328, v1.26: the flip also records caller_output_json — the D1 read, sketched above. The ordering predicate is exactly the draft-write clock §11 pins: updated_at moves on every draft write and never at release, so "started after the last draft write" reads precisely "ran THIS body". A never-run draft releases with a NULL record; the response and audit row say caller_output = not_observed and the release is never refused for a missing run. The record's shape, bounds and the reader's precedence are pipeline-contract §3.3.1.)

Preconditions, evaluated server-side before the statement runs:

  1. The expected hash matches the draft (§4.2) — you release what you tested.

  2. Every dependency in the draft's release closure is RELEASED, or flips in the same release — dependencies lock first (#517, release-dependencies record §1, §4). The closure is every template version the body pins and every child pipeline version a PIPELINE node pins, transitively, enumerated within the configured bounds (configuration §3.35: depth, versions, edges, wire bytes — over a bound refuses pipeline.release.dependency_limit, a cycle pipeline.release.dependency_cycle). A dependency that is not RELEASED and not flipped by this request refuses pipeline.release.dependency_not_released in the shared blockers shape ({root, blockers:[{node_id, family, name, version, status, reason, paths}], blockers_truncated}, REST §5.10). (Pinning a DRAFT dependency from a DRAFT pipeline is legal while iterating — §6 — and only becomes an error at release time.) A DISCARDED or MISSING pin is never releasable; a hidden pin answers exactly as an absent one.

    What the request consents to flip. Explicit consent, never implied; the default is the refusal above.

    • No consent — only the pipeline flips.
    • The legacy alias (142, owner 2026-09-15: "a headache-free promotion") — releasePinnedTemplates = true, ?release_pinned_templates=true over REST, the checked-by-default "Also release these N draft templates" group in the release dialog. It selects exactly the root's DIRECT DRAFT template pins, each released at its PINNED version and nothing newer; a deeper DRAFT is a blocker. Its success response and audit keys are release 142's.
    • A release plan (#517) — tree_hash (the preview digest of the closure the caller reviewed), include_dependencies (the DRAFT nodes it consents to flip; every reachable DRAFT must be selected) and optionally check_overrides (a node's own release-check override reason). The digest is compared before any validation or check work, and again inside the transaction under the writer guard; any drift between preview and submit — a body changed in place without a version change included — refuses pipeline.release.plan_conflict. The snapshot includes the resolved entity UUID as well as the body hash; purging and recreating an identical name/version cannot reuse consent. Native preparation reads the body and its database hash together; validation, checks, released entity metadata and the exact-version CAS use that same body. The caller must hold each flipped family's release permission.
    • The arming rule — the alias together with include_dependencies is pipeline.release.plan_invalid (two authorities); any plan field without tree_hash is 428 pipeline.release.plan_required. The record's 428 for a BARE alias (§17 row 6) is armed by one constant, LegacyReleaseAlias.LEGACY_ALIAS_WITHOUT_PLAN_ADMITTED, which stays true until the release dialog sends tree_hash (#522): until then a bare alias is the legacy cascade above, unchanged.

    One transaction, strictly audited. Every release — consent or none — runs in ONE metadata transaction: the workspace writer guard first (below), then the dependencies flip dependency-first, each at the hash the closure read, the pipeline last; one *.version.released audit row per actual flip is written in that same transaction, and an audit store that refuses the row refuses the release. A refusal anywhere — a dependency's own preconditions, a stale hash on the flip, a concurrent write, the audit row — rolls the whole transaction back, so no dependency is ever left released with the pipeline still a draft. Each cascaded row keeps the direct verb's shape (a template's cascade_from_pipeline_id / cascade_from_version) plus cascade_root; every row of the release shares release_id and carries scope: "version"; the pipeline's row keeps templates_released: [{template_id, version}] (an empty list when nothing cascaded) and gains dependencies_released. Each dependency flips by compare-and-set at the exact version and hash the closure read — the pin names the exact version, so there is no second hash handshake per dependency. The release check gate (precondition 4) runs before the transaction, for each flipped node against its draft. Promotion receive is unaffected: promoted content arrives RELEASED (§10), so no consent is exposed there. The same role holds both verbs (auth §7.6, "Release a version"), so consent grants no new capability; the MCP surface is untouched — no tool releases anything.

    The workspace writer guard (#517, record §4.3). A release must judge a closure no concurrent write can change under it. Every metadata write that changes what a release resolves — a pipeline, template, parameter-set, visualization or dashboard save, discard, restore, purge, manual switch or import, and a visualization test session's open and submit — runs in a metadata transaction that first locks the workspace's own workspaces row (SET LOCAL lock_timeout from the server-validated lock-wait-seconds, then SELECT … FOR NO KEY UPDATE). Writers in one workspace serialise; other workspaces never wait. A wait past the bound answers the writer's family <family>.release.busy and nothing is written.

  3. Full pipeline-contract §12 validation re-runs on the draft body. Release is the final save-time gate; nothing is released that the validator would refuse.

  4. Every release check the body carries passes (140). When the draft body declares checks[], the server runs them NOW — a fresh run, via = release, one pipeline_check_runs row per check — BEFORE the flip, and outside the metadata transaction (the runs open customer-datasource connections). Any fail or error verdict refuses the release with 409 pipeline.check.failed, whose details.checks lists every failing check with its expected and observed values and the run message — UNLESS the request carries override_checks_reason, a non-blank string of at least 10 characters, in which case the release proceeds and the pipeline.version.released audit event gains checks_overridden: [ids] and override_reason.

Checks are opt-in: a version with no checks[] releases exactly as before. The agent never supplies an observed value — it writes the query and the expectation; only the server's own run produces observed, so the gate cannot be talked past.

5.4 Purge the draft (was "discard", 101)

The draft verb is purge (§3.1): the row is hard-deleted together with its executions — development runs of a thing that never shipped are not history, and the executions FK must never force a tombstone. The service deletes the execution rows in the same transaction (the constraint stays NO ACTION; the delete is explicit, auditable and testable — metadata-db.md documents the choice), then the version row; when it was the sole version the entity row goes too (the entity purge, §3.2).

Before every destructive write, both pipeline draft purge services ask the pin question: live exact parent/dashboard pins for a nonsole draft; the existing entity guard for a sole draft (live exact parents, stored any-version dashboard pins). A blocker refuses 409 pipeline.version.pinned, naming the parent pipelines and dashboards, and preserves the draft hash, executions and pointer. The hash-guarded path checks the supplied hash first; a stale hash retains pipeline.version.conflict. The POST repeats this check even if a dialog GET found no blockers. The concurrent pin/write window remains as stated in §3.5.3.

DELETE FROM pipeline_executions
 WHERE (pipeline_id, version) = (:id, :draftVersion);

DELETE FROM pipeline_versions
 WHERE pipeline_id = :id AND version = :draftVersion
   AND status = 'DRAFT' AND body_hash = :expectedHash

Redis result keys are not deleted — they expire on their own TTL, and a purge leaves zero orphan execution ROWS (asserted by test); an expired key is not a reference. If the purged draft had become current_version (the development fallback of §3.4), the same transaction recomputes the pointer. 0 rows on the version delete ⇒ 409, as before.

5.5 Drafts are a deployment capability (039)

D7 says "receiver environments never author" — nothing enforced it. Now something does, mechanically, as one block of deployment-role settings (configuration.md §3.19; the codebase groups config one-per-concern — auth, executor, staging — so deployment is the house shape):

datapipelines:
  deployment:
    name: dev                      # LABEL ONLY — nothing branches on it (below)
    authoring-enabled: true        # the capability this section enforces
    promotion:                     # RESERVED — not implemented as of this round (§10.6)
      server-key: ...              # INBOUND: what I accept (receiver role)
      target:                      # OUTBOUND: where I push (sender role)
        base-url: ...
        server-key: ...
  • deployment.authoring-enabled, default true — named for the CAPABILITY, not the environment (the boot refusal covers PIPELINE, TEMPLATE and — since #194 lane D, C14 — parameter-set drafts: AuthoringStartupCheck refuses startup on an authoring-disabled deployment holding any of the three): someone runs this on ONE box, authors there and runs there, and that server is "production" in every ordinary sense, so a key that gated authoring on an environment NAME would lock them out of the only server they have. A promotion receiver turns it off; everyone else is on by default.
  • deployment.name is a LABEL, and nothing may branch on it. This is the rule that makes the bundling safe rather than a regression — putting the label next to the capability re-creates exactly the temptation the capability naming exists to avoid. Its one consumer is the startup posture log line (name + authoring state); it is not on /info (permitAll), and a guard test pins that no production code reads it in a conditional — stronger, that no production source names the key at all beyond its log consumer.
  • Enforced at the write path, fail-closed (C3), with the catalogued refusals pipeline.authoring.disabled / template.authoring.disabled (§13.13/§13.9, 403) naming the reason. Every authoring write refuses on a receiver: pipeline/template create, update/draft, release, discard and delete — REST and MCP alike, at the shared services. Reads, execution and import are unaffected.
  • Draft creation hangs off the WRITE path only — never off "a released row was written". This is the trap this section exists for: promotion imports RELEASED versions into a receiver, and any draft-creation logic attached to the released-row path would fire on every import and create drafts on exactly the deployments this rule forbids them on. The import paths (§9.2) insert RELEASED rows directly and never open drafts; the round's tests assert it.
  • Boot checks (configuration.md §7): a deployment with a promotion server-key configured — meaning it RECEIVES — AND authoring enabled WARNs loudly (D7's violation stated in config) but does not fail (a one-box deployment may legitimately be both). This check is currently one-sided: the promotion sub-block is reserved for the round that implements it (§10.6's fenced sample), so the authoring half ships now and the promotion half slots into the check's seam without rework. Authoring disabled while DRAFT rows exist REFUSES startup, naming them: someone authored on a receiver and version alignment may already be broken (§9.3) — better found at boot than at the next promotion's 409.

6. Templates: Same Lifecycle, Plus the Pin Rule

Parameter-set create, draft write and validated import (including promotion receive) recheck template pin existence, live status, type and dialect after acquiring the workspace writer guard. Customer selector probes remain outside the metadata transaction. A purge or discard that wins before the guard makes the set writer refuse without storing a pin.

template_versions gains the identical statuses, hash column, one-draft index, and four write paths (mirrored codes template.version.conflict, template.version.not_draft).

The template metadata asymmetry (v1.3). §3.7's metadata-rides-the-release is a PIPELINE rule, and it works there because the pipeline body carries its metadata — the draft row stages it inside body_json. A template's display_name/description live on the index row templates only and are not part of the versioned artifact, so a template draft stages what CAN be staged (engine/dialect/is_library/imports/body — exactly the canonical body of §4.1) and the index metadata keeps moving at save time. Templates have no rename (name is the identity), so §3.7's draft-write-time name check is pipeline-only. Template draft purge is likewise simpler: nothing references a template_versions row by FK (pipeline pins are numbers in JSON), so a template draft is always hard-deleted with no execution cleanup to do. A RELEASED template version is discarded/restored exactly like a pipeline release (§3.5's table, template code spellings), and template.in_use — the pin guard that already guarded entity delete — is the code for a pinned version's discard/purge too.

Because drafts pre-allocate real version numbers, a DRAFT pipeline can pin a DRAFT template version by its number — this is the intended editor loop (edit SQL → run the node → tweak → release), and it is why drafts live in the version tables (D1). The rule that makes it safe:

A pipeline may be released only when every template version its body pins is RELEASED. Draft pins are legal while the pipeline itself is a draft.

Release ordering is therefore always templates-first. Pins are immutable references, so no cycle is possible.


7. REST Surface Changes

Additive; existing routes keep their shapes. Exact wire contracts land in rest-api.md at implementation.

Route Change
POST /api/v1/pipelines Creates v1 DRAFT (§3.6, D55). Response carries status: "DRAFT", version: 1, body_hash, current_version: null and the draft pointer. Releasing it is POST …/release like any other draft.
PUT /api/v1/pipelines/{id} Semantics change: always writes the draft branch (§5.1 or §5.2). Never appends a released version. Requires the hash precondition. Response carries the version's status and body_hash. A body identical to the released one is a no-op (§5.1): no draft, no burned number, and the response reports the current RELEASED state with no draft pointer.
POST /api/v1/pipelines/{id}/release New. Hash-guarded (§5.3). UI-only in practice; no MCP tool is exposed for it (D4).
POST /api/v1/pipelines/{id}/draft/discard The editor's draft verb — now a purge (§5.4): the draft row and its executions are deleted, no tombstone. Still hash-guarded, still 204.
POST /api/v1/pipelines/{id}/versions/{v}/discard New (101). Discards RELEASED version v (§3.1): row flips to DISCARDED, pointer per §3.4. Refusals: not_released, pinned, authoring.
POST /api/v1/pipelines/{id}/versions/{v}/restore New (101). Returns a DISCARDED version to RELEASED; pointer moves only if v > current or current is NULL.
DELETE /api/v1/pipelines/{id}/versions/{v} New (101). Purge, drafts only — the row and its executions go; sole draft ⇒ the entity goes with it. Refusals: last_release, pinned.
DELETE /api/v1/pipelines/{id} Semantics change (101): the entity purge — allowed only when the only version is a DRAFT (last_release otherwise), with include_exclusive_draft_templates (§3.5); the response carries kept_draft_templates (#320). Replaces the V1 soft delete, which V19 retires.
POST /api/v1/pipelines/{id}/current New (101). Manual switch — body {"version": v}; v must be live and eligible (not_eligible). Not authoring-gated: the promotion receiver's verb.
POST /api/v1/pipelines/{id}/execute With no version, runs the working version (§7.2, D56) — the draft when one exists, else the latest release. An explicit version is still exact and never clamped. The execution record pins the version that ran, and the executions screen shows DRAFT beside a draft run.
GET /api/v1/pipelines/{id} Read shape gains the version's status and body_hash, current_version, and the draft pointer when one exists. Since 039 the default body is the working version (§7.1): the DRAFT when one exists, else the current released version.
GET /api/v1/pipelines (and ?prefix=) Each row's version is the working version and a new status field says DRAFT or RELEASED — since D55 a listing that reported current_version alone would show nothing for every freshly authored pipeline.
GET /api/v1/pipelines/{id}/export A pipeline with no RELEASED version is refused with pipeline.promotion.not_released (409, "release it from the UI first"): an export feeds a promotion import, which lands RELEASED content.
POST /api/v1/endpoints Publishing over a pipeline whose current_version is null is refused with endpoint.pipeline_not_released — the code and the check already existed; since D55 it is an ordinary state rather than an unreachable one.
Templates (/api/v1/templates/...) Mirror of all the above (release = §8.9, discard = §8.10 in rest-api.md) — addressed by name in query/body since rest-api v2.0 (§9.6: the name never travels in a path segment). The 101 verbs are twins by name: `POST /api/v1/templates/version/discard

The 101 verbs are human verbs (D4 family). All carry MUTATE_PIPELINES_TEMPLATES and are session-only — an API key, however scoped, is refused (auth.session.required); there is deliberately no MCP tool for any of them (§12.2's rule, applied to purge/discard/restore/switch exactly as to release). The one non-session exception is POST …/current for an operator rolling a receiver forward or back — still session-only on REST; receivers are driven from the UI. Every verb emits its audit event (§3.5 of enums.md §15): pipeline.version.discarded / restored / purged, pipeline.purged, pipeline.current_switched, the template twins, and — since #332 — the same five for the parameter-set, visualization and dashboard families (their release events too, each cascade named cascade_from_<family>_id, the pipelines mould's auditRelease twin).

7.1 Authoring reads return the working version (039)

The principle: a RELEASED version is never modified, and if a draft exists it is reused, no matter what. Authoring reads — REST GET /pipelines/{id} and GET /templates?name=..., the MCP pipelines_get / templates_get tools, and the editors — therefore return the working version: the draft if one exists, else current_version. They always state which version and which status they returned; the caller never infers it. Before 039 the REST and MCP default was the released body with a draft pointer beside it — an agent that read released while a draft was open would rebase its edit on stale content and quietly discard the draft with its next write.

Three pins:

  • current_version is NOT repointed at the draft. It means latest released, and execute-default, the datasource reverse-scan join and the editor's fallback all read it that way — repoint it and executions run unreleased code. The working version is DERIVED (draft-exists ? draft : current), never stored; there is no schema behind this section.
  • Explicit reads still win. An explicit version argument (or versioned URL) returns exactly that version; this changes only the DEFAULT, and only for the authoring surfaces. Execution keeps reading current_version. Superseded by D56 (§7.2): execution's default is the working version too.
  • The no-op rule (§5.1) is what makes this safe to act on: a draft exists iff the content genuinely differs, so "the draft is the working version" is never a phantom an unchanged save left behind.

Exact spellings (v1.3, fixed in rest-api.md): the hash precondition travels as the standard If-Match request header carrying the body_hash — on PUT, release and discard, for pipelines and templates alike; absent/blank ⇒ 400 pipeline.execution.invalid_parameter_type with details.reason = "precondition_missing" (a caller that did not participate in the protocol at all, not a conflict). Release and discard distinguish their refusals: no DRAFT exists ⇒ pipeline.version.not_draft / template.version.not_draft (409); a DRAFT exists but the hash is stale ⇒ *.version.conflict with the current state in details. The executions surfaces carry §8's marker as a draft_run: true|false field on each execution record.

MCP authoring (the pipelines_update tool, and templates_update (117) for templates — the same TemplateDraftService.write call PUT /templates makes, with the If-Match hash as the required expected_hash argument) calls the same PUT and therefore lands its work as drafts on existing entities. An agent iterating produces one draft row that it keeps overwriting — the pile the old PUT-per-save semantics created is gone. A human releases from the UI when satisfied (D4). The pipelines list gains a "drafts pending release" badge so unreviewed agent work is visible.

7.2 Execute with no version runs the working version (D56, 099)

The default everywhere is the working version: the draft when one exists, else the latest release. It is the rule §7.1 already applied to authoring reads, now applied to running as well, and it is what "we always run the LAST version" means:

Surface Default with no version
POST /api/v1/pipelines/{id}/execute working version
pipelines_execute (MCP) working version
datapipelines://pipelines/{id} (MCP resource, no /versions/{n}) working version
pipelines_get, GET /api/v1/pipelines/{id} working version (§7.1, unchanged)
The UI read workspace (GET /pipelines/{id}, #348) the ACTUAL current pointer when its body is admitted (a development-posture current draft shows as the draft it is, never rewritten as a release), then an accessible draft, then a choose-a-version state — never "latest release"; an explicit ?version=N renders exactly or 404. The workspace's own execute POST always carries its viewed version, so this default never decides a run. #349: the viewed version moves IN PAGE (the selector and the Versions tab fetch the admitted body through the REST version read — never a document reload, so an active run keeps its stream), and the viewed-version state the client carries is the lens-filtered admitted history the page resolved — a hidden current or draft never enters it.
A published endpoint (GET on the published tree) whatever the pointer names — in development that may be a draft (D63, §3.4 "A draft pointer"); outside development no draft exists, so released only by construction
Promotion candidates and the promotion push released only (D6); unchanged

One place resolves it: PipelineService.workingVersion(workspaceId, record). It was three inline copies (the REST execute controller, PipelineExecuteTool, pipelines_get) before 099, which is exactly the drift the D6 refactor removed from the LOOKUP and left in the DEFAULT.

Posture. Under authoring-enabled=false (the hardened default, configuration.md §244) no draft can be created at all — create, update, release and discard are all refused with pipeline.authoring.disabled (§5.5) — so on a non-development server the working version is a RELEASED version by construction, and no configuration says otherwise. That is the ruling's second half ("on servers which are not development, we will always have RELEASED pipelines, no DRAFT at all") held up by a mechanism rather than by a promise.

Two consequences worth stating:

  • A draft run is marked. The execution record already pins the version that ran; the executions list and detail render DRAFT beside a draft run (§8, ui-screens §4.8) so a reader is never guessing whether a result came from reviewed content.
  • There is one "nothing to run" case at the DEFAULT, and it is not a fresh pipeline: creation always writes version 1, so the only way to a version-less pipeline is purging the sole draft of a never-released one (§3.2 — the purge deletes the entity with it). Execute answers 404 pipeline.execution.not_found there — the same refusal an out-of-range explicit version gets, with details.pipeline_version naming it. A pipeline whose every release is DISCARDED (entity DISCARDED, pointer NULL) answers the same refusal at execute-default, and an explicit execution of a DISCARDED version is refused the same way — a retired version is not executable; restore it first (§3.1).

8. Executing Drafts

Drafts are executable — the editor's test loop depends on it (single-node run, full-draft run). Because a draft is a version row, the existing composite FK records the run against the real draft version number; nothing about the execution schema weakens.

  • A draft run is DERIVED, not recorded (ratified 2026-08-31). For an execution of version N: it was a draft run when started_at < released_at, or when that version has no released_at (still DRAFT). No schema column. The derivation is sound because a version's lifecycle is one-way — RELEASED never returns to DRAFT (§3.1: a write after release opens a NEW version), numbers are never reused (§3.3), and 101's restore returns a DISCARDED version to RELEASED keeping its original released_at — so each (pipeline_id, version) has exactly one released_at to compare against, forever.

    The precondition, stated because it is not obvious. started_at is application-supplied, not a database default: ExecutionRepository binds :startedAt from the record (its INSERT names started_at explicitly). So this comparison spans two clocks whenever the release and the execution are served by different instances. The failure window is sub-second and either side of a release; the consequence is a history LABEL, never execution behaviour, never promotion eligibility (§10.3 reads status, not this derivation). released_at must therefore be set by the database (NOW()) at release, so at most one of the two timestamps can drift. If a future requirement makes the draft/released distinction load-bearing rather than informational, record it instead of deriving it.

  • Draft executions appear in history with a draft marker; they are never promotable and never count as validation for release (the human decides that).


9. Cross-Environment Identity and Import

9.1 Why version numbers must be global identities (verified defect)

Both import paths renumber today: pipeline import strips the version field (PipelineImportService SERVER_FIELDS) and allocates current_version + 1; template import calls the same bump-and-append. That breaks the export bundle round-trip silently: a dev pipeline pins acme/lib/foo.sql@2, the bundled template lands on a fresh target as acme/lib/foo.sql@1, and the pipeline's pin fails template_version_not_found. The export/import pair only round-trips when both environments' numbers happen to be in lockstep — i.e., never after any drift. Pins are version numbers; cross-env renumbering is a correctness-breaking hazard, not a cosmetic one. D5 fixes this by construction.

9.2 Import with preserved versions

The import payload may carry version (promotion always sends it). When present it is honored; when absent, today's allocate-next-local behavior applies. Target-side rules:

Target state for that (id, version) Hash vs target Result
Absent — Insert as RELEASED at that exact version (released_at from source), with source's body_hash. The pointer does not move (D60) — except when the entity has no current at all, when this first import sets it.
Present, RELEASED Same Idempotent no-op (200). Re-importing an old export is safe.
Present, RELEASED Different 409 pipeline.import.version_conflict, both hashes in details. Never overwrite.
Present, DRAFT (local draft) any 409 pipeline.import.version_conflict — a local engineer's draft is never clobbered.
Present, DISCARDED any 409 pipeline.import.version_conflict — discarded numbers are never reused.

The pointer rule on import (D60, 101). An import never moves an existing pointer: promoting v5 onto a receiver whose pointer names v3 inserts the version and leaves the pointer at v3 — the human switches when they are ready (POST …/current, §7). The single exception is the FIRST import of an entity that has no current at all (a never-released entity, or one whose every release was discarded): nothing exists to break, so the pointer is set to the imported version. Index metadata (name/display_name/ description) rides the pointer, not the number: it adopts the imported body's values exactly when the import moved the pointer. Version-less imports (no version in the payload) allocate max(version) + 1 — never current_version + 1 (the pointer can be NULL or stale) — and follow the same pointer rule. Gaps in the number sequence are expected and harmless.

Hash recompute guard: the target recomputes the body hash from the payload body and refuses with pipeline.import.hash_mismatch if it differs from the declared hash — this catches transfer corruption and canonicalization drift between app versions in one place.

Wire spelling (v1.3): a payload carrying version must also carry body_hash (absent ⇒ hash_mismatch with details.reason = "body_hash_missing"); released_at is honored when present (export emits it) so §8's derivation stays truthful on the target. The stored released_by is the IMPORTING actor — the source's releasing user may not exist locally (the promotion case is §10.6's service principal, out of this round). The template import mirrors the table entry-for-entry with template.version.conflict; the template-side hash-mismatch code does not exist in §13 and this round adds no rows, so the internally-inconsistent entry surfaces as template.version.conflict with details.reason = "hash_mismatch" — a catalog gap raised to the operator, not papered over.

9.3 The price of number preservation, accepted

If a receiver ever authors locally (a process violation, D7), its local version numbers collide with future dev releases. The outcome is a loud 409, resolved procedurally (re-do the change in dev, release, promote). The design fails safe — it never silently diverges, and with receivers that never author, the price is never paid.


10. Promotion (UI-Driven, Separate Use Case)

Against exactly one configured higher environment (base URL + the pre-shared server key of §10.6; the keys themselves are defined in configuration.md §3.19, not restated here, per that doc's sole-authority rule). Shipped by 055; the receiver's two endpoints are specified in REST API §18.

10.1 Policy

  • No hotfixes (D7): a bug fix is a new release in dev, promoted like any change. Receivers never author; the sole writer of a receiver is promotion.
  • Promotion is triggered by a human from the UI (D8). No MCP tool. No schedule, no auto-promotion.

10.2 The listing rule (what the UI shows)

The promotion screen lists only pipelines that are RELEASED and whose current version number is greater than the target's current version for that pipeline (a pipeline the target does not have counts as target version 0). Drafts are never listed. The same holds when the pointer itself names a draft — §3.4's development-posture fallback or a manual switch (#563): an object whose current version is a DRAFT is not promotable and is invisible to a promoter whatever releases it holds; it returns when the pointer names a release. Same-version entries are never listed.

The wire inventory (REST API §18.1) obeys the same released-pointer rule for all five families, omitting draft-pointer and never-released objects (#575).

The same rule is the promoter LENS (178, Auth §11A.1). Since R2 a promoter sees, on EVERY read surface — lists, levels, counts, search, gets, the editor source, the MCP tools and resources — exactly the set this screen lists, for pipelines AND templates: the rule is computed once per request (PromotableView) and the page and the lens read the same object, so they cannot disagree. The template arm, stated for the first time here (the push closure only implied it): a template is promotable iff it has a current RELEASED version and the target lacks its id, or holds a different hash at a lower version — the four lines above with the template's id for its name. A hidden object answers a promoter exactly as an absent one, and a visible object's pending DRAFT is invisible as well — the working version a promoter reads (§7) is the current release, never the draft — on every read that resolves version content, the node SQL, the checks and the used-by pins included (178b); when the target cannot be read the lens shows NOTHING (fail closed) and the screens say why.

The delta is computed from the target's inventory: the promotion orchestrator reads the target's per-pipeline and per-template (name, current_version, body_hash) (REST API §18.1) and compares. By name, not by id (the shipped correction to this paragraph's first draft): workspace and template names are a global namespace and pipeline ids are globally unique but locally opaque, so a name is the one identifier that means the same thing on both deployments. The comparison is:

  • Same body_hash ⇒ nothing to push (version for humans, hash for machines).
  • Different hash or absent ⇒ the pipeline is promotable; only its latest RELEASED body is pushed (D6) — intermediate released versions are not transferred; the target executes current_version only and its own history references its own numbers.

10.3 Server-side guards (independent of the UI)

The UI rule is convenience; the server enforces the same constraints regardless of caller:

  1. The promotion orchestrator refuses to push a pipeline whose candidate version is not RELEASED → pipeline.promotion.not_released.
  2. It refuses to push a version not greater than the target's current version for that pipeline → pipeline.promotion.not_newer. Same-version pushes are a bug, not a no-op to swallow.
  3. The receiver enforces §9.2's import table — conflict, idempotency, hash recompute.

Both ends guard; a client bug cannot smuggle a draft or a stale version through.

10.4 Push order (dependency closure)

Per promotion batch, push in topological order:

  1. Template versions referenced by any pushed pipeline (direct node refs plus the transitive imports_json closure — the export bundle already computes this set), plus — since #10 L1c — the transform pins of any pushed visualization.
  2. Child pipelines referenced by PIPELINE nodes (recursively — the export bundle does NOT include these today; promotion computes the closure itself).
  3. The pipelines, children before parents.

The receiver applies the batch in ONE transaction in that order, then — since #10 L1c — the parameter sets' entries and, LAST (D61), the batch's visualizations and dashboards: the transfer families bind through the families' strict readers and land inside the transaction, where the just-landed templates and pipelines are what the import lens resolves against. A refusal at any entry rolls the whole batch back — the templates included. Since #517 the batch's transaction acquires the workspace writer guard FIRST (§5.3), so no release, purge or discard in the receiving workspace interleaves with a batch; a wait past lock-wait-seconds refuses the batch with pipeline.release.busy and nothing lands.

The transfer arms carry one family each (the L1c-b round, O1): a dashboard root's pinned visualizations travel as entries of the visualizations arm — deduplicated by name + version

  • body hash against the explicit visualization roots — and the dashboards arm carries dashboards alone, because each arm's entries are bound by that family's reader. Both arms are count-bounded before the transaction opens (O7): at most datapipelines.visualization.max-visualizations-per-dashboard entries, refused body_invalid with reason: too_many. The ceiling is AGGREGATE (the L1c-c round): the value also caps each whole arm as an ADDITIONAL ceiling on top of the per-document bound the key's name describes — two dashboards that are each individually valid can together exceed it, and such a batch refuses WHOLE (no partial writes, no automatic split); a genuine batch at exactly the configured ceiling lands whole. A landing is judged by the RELEASE rules (O2): a batch entry lands RELEASED, so a pin it carries that exists on the receiver but is not RELEASED refuses with the family's release code (visualization.release.dependency_not_released / dashboard.release.dependency_not_released); a pin the receiver lacks at all stays the import lens' precise import.missing_dependency. The refusal at ANY entry rolls the whole batch back — witnessed end to end by the transfer E2E's refused-dashboard receive case (the L1c-c round): earlier batch members land, the dashboard refuses on landing, and no template, visualization, version, current pointer or accepted-audit row survives.

Pins are immutable and cycle-free, so the order always exists. Templates/pipelines already present at the same version and hash are skipped (idempotent).

The closure is read at the exact PINNED versions, without the promoter lens (the owner's ruling of 2026-10-02, #344; Auth §11A.1's lens clause). §10.3's guards and the lens govern the ROOTS a human selects — a root the lens hides refuses with its family's 404 — never the dependencies they pin: a pinned template §10.2's view hides (already on the target, or behind it) still rides, and the skip rule above is the only thing that leaves one out. The export bundles (rest-api §5.9 and the families' export routes) follow the same rule.

10.5 Datasource pre-validation

Pipelines reference datasources by name. Before pushing anything, the orchestrator collects every datasource name the batch references and verifies each exists on the target; a missing name fails the whole batch with one consolidated error (mirroring the import service's combined missing_datasources report) rather than failing mid-batch.

A promoted pipeline's release checks travel in its body (140: checks[] is body content, versioned like nodes). The receiver re-runs them on its OWN datasources at its release step — the same §5.3 gate a human release faces, with no override channel: fail or error refuses the batch with pipeline.check.failed. A receiver without a check's datasource records error, which is the truth — the alternative is importing a number nobody verified.


10.6 The promotion-peer credential — a shared server key (ratified 2026-09-01)

§10.1 said "base URL + API key", which was under-specified: an API key here is workspace-pinned and user-owned, and neither property is right for one deployment writing to another.

Ratified shape (operator, 2026-09-01): a pre-shared server key, not a principal. Shipped 2026-09-02 (055). The RECEIVER holds a long secret in configuration; the SENDER holds that same secret alongside the target's base URL and presents it on the promotion call; the receiver validates it before accepting any payload. No service account, no users row for the credential itself, no scope matrix entry — promotion is a deployment trusting a deployment, and modelling it as a principal was more machinery than the problem needs.

Property Value Why
Receiver config a long secret under the promotion key shown below The receiver is the one that must be able to refuse.
Absent key Promotion disabled, endpoint refuses Fail closed. A deployment that never configured a key must not silently accept pushes.
Sender config target base URL + the same secret Held with the sender's other deployment secrets — never in a pipeline body (§2 principle 1 forbids env-specific values there).
Transport A request header, TLS only, compared in constant time, never logged It is a bearer secret; a timing-safe compare and a redacted log line are the whole discipline.
Scope The promotion/import endpoint ONLY It is not a master key. No other route consults it, and it grants no read access.
Direction Receiver validates; receiver never calls the sender A compromised receiver cannot reach back into dev.
Rotation Change both sides; no user account is involved The property a user-owned key could not give: rotation and revocation touch no human's account, and offboarding cannot break production promotion.

The configuration shape. It lives inside the datapipelines.deployment block (§5.5's amendment — the deployment-role settings are grouped, and the promotion sub-block mirrors the inbound/outbound split this section draws). It is now shipped and documented in configuration.md §3.19, which is the operator-facing authority; the sample below is retained as this section's own illustration:

# receiver (e.g. uat) — absent means promotion is refused
datapipelines:
  deployment:
    name: uat
    authoring-enabled: false           # receivers never author (D7, §5.5)
    promotion:
      server-key: ${DATAPIPELINES_DEPLOYMENT_PROMOTION_SERVER_KEY:}

# sender (e.g. dev) — the target and the same secret
datapipelines:
  deployment:
    name: dev
    promotion:
      target:
        base-url: ${DATAPIPELINES_DEPLOYMENT_PROMOTION_TARGET_URL:}
        server-key: ${DATAPIPELINES_DEPLOYMENT_PROMOTION_TARGET_KEY:}

The implementing round adds the block to configuration.md in the commit that makes it real.

The gap this shape did not close, and how it was closed (R7, ratified 2026-09-02). pipeline_versions.created_by and pipeline_executions.triggered_by are NOT NULL REFERENCES users(id). A payload authenticated by a server key carries no user, and the source deployment's user ids are meaningless on the receiver — different users table. So an imported row still needs an actor that exists locally.

Three options were weighed: a reserved non-interactive users row; making the column nullable with a CHECK; and mapping by email (rejected outright — it silently creates or mis-attributes when the human has no account on the receiver).

The ruling took the first option's shape but not its mechanism: a system service account provisioned at first boot, not created by a migration, and used for promotion AND for every other non-user-bound write the system makes now or later — the retention job, the stale-execution sweeper, anything automated that comes after. It is provisioned through the same createUser path the bootstrap admin uses, with the same create-if-absent and insert-race semantics, and read back through ONE well-known lookup so nobody mints a second actor. Login is disabled by construction: its provider name is reserved at startup validation, its address is under RFC 2606's unresolvable .invalid TLD, and the local-password paths refuse it. The full contract is Auth §4.5.

The FKs therefore stay NOT NULL and no schema change was needed. Every promoted row is stamped with that actor, and the receiver's audit event carries the provenance the row itself cannot: source_env (the sender's deployment.name) and a truncated fingerprint of the key that authorised the push — never the key. The releasing human stays recoverable from the artifact's own released_by on the source deployment.

The machine-auth note's F10 defers to this section; F2's service-account question is answered only for the ACTOR half — an application EXECUTING a pipeline is a different problem from a deployment PROMOTING one, and D16's own headline is that execution is blocked by the SSE contract before auth is even reached (R9).

11. Schema Changes (Amendment for Implementation)

metadata-db.md remains the sole DDL authority (its rule D4); these land there at implementation time, in the same commit as the repositories:

pipeline_versions and template_versions gain:

Column Type Notes
status TEXT NOT NULL DEFAULT 'RELEASED' + CHECK (status IN ('DRAFT','RELEASED','DISCARDED')) Existing rows backfill as RELEASED — zero data migration semantics.
body_hash TEXT NOT NULL Backfilled for existing rows (pgcrypto digest or a startup task).
released_at TIMESTAMPTZ NULL NULL for drafts; set at release.
released_by UUID NULL Actor of the release.
updated_by UUID NULL Last draft writer — powers the 409 details.
updated_at TIMESTAMPTZ NULL Written by DRAFT writes only and never restamped afterwards — a released row keeps its last draft-write timestamp (the immutability note in metadata-db §4.5/§2 is amended accordingly).

Plus the two one-draft partial unique indexes (§3.3). No column is added to pipeline_executions — §8 derives the draft/released distinction from released_at, which is why that column is database-generated (NOW() in the release statement, not DEFAULT — it is NULL until the first release of that version) rather than application-supplied. The migration is V6__version_lifecycle.sql; metadata-db §4.5/§4.9 carry the DDL (the authority) and §5A carries the promotion classification registry the migration test parses.

The immutability KDoc blocks on both repositories are rewritten to the §3.1 discipline.


12. The Agent-Facing Skill Must Land With the Code

modules/mcp-server/src/main/resources/skill/core.md (served as /skill.md, #242) is what an agent reads before it touches this product. It is not documentation about the system — it is the system's instructions to its own callers, and MCP agents are the primary authoring surface here (contract §2; the editor is a viewer/executor). A lifecycle change that reaches the API without reaching the skill leaves every agent operating on the old contract.

This is not hypothetical for this spec. SKILL.md:51 currently states:

Versioning — every save creates a new version.

D2 makes that false. After this round a save on a released version creates a DRAFT, and a save on a draft overwrites it in place. An agent holding the current skill will believe its pipelines_update published something, and it did not.

The rule: the skill is updated in the SAME commit as the behaviour it describes — the same discipline §13 applies to catalogue rows and their constants, for the same reason. A skill that lags is worse than one that is silent, because agents act on it with confidence.

Audited 2026-08-31 at 7f87118: the skill is otherwise accurate, so :51 is the only lie this spec introduces. All 18 McpToolCatalog.NAMES tools are named in it, the "18 MCP tools" count matches, and it already discloses the REST/MCP execute asymmetry the architecture audit records as D6 (SKILL.md:146 — "REST execute accepts Idempotency-Key; the MCP tool has no key"). Do not update the skill for this spec before the behaviour ships — a skill describing an unimplemented lifecycle is the same defect in the opposite direction, and the architecture audit's M11 exists because three such claims are live in deployment.md today.

12.1 What this spec obliges the implementor to change

Skill content Why it changes
§"Core concepts" → Versioning (:51) "Every save creates a new version" becomes the draft/release rule: create lands v1 RELEASED and immediately executable; every later save is draft-first (§3.2).
§"The golden path" The authoring loop gains its real shape: create → iterate on the draft → stop. The agent does not release (D4). Say so positively — "leave the draft for a human to release" — not as an omission.
§"Execution semantics agents must know" Drafts are executable, and a draft run is not a release (§8). An agent testing its own draft is the expected loop, not a workaround.
§"Error handling" The new 409s an agent will actually meet: pipeline.version.conflict (stale content hash — re-read and rebase, never retry blindly), pipeline.version.not_draft, and pipeline.validation.duplicate_name now arriving at draft-write time (§3.5).
§"Best practices" The hash precondition is a protocol, not a nuisance: read, edit, write with the hash you read. A blind retry after a 409 is how an agent silently overwrites a human's edit.
§"References" Add docs/versioning.md.

12.2 What the implementor must decide, not assume

The skill says agents author and the UI releases. Whether pipelines_update returning a draft is surfaced as a distinct tool result, or as the same result shape with a status field, changes what the agent can reason about — and the MCP tool surface is mcp-server.md's authority, guarded by McpToolSurfaceSpecDriftTest and the McpToolCatalog binding (033). If the round adds or reshapes a tool, that spec, the catalogue and the skill move together; if it only changes a payload, say so explicitly in the handback so the drift guards are known to have been considered rather than missed.

No new MCP tool for release — D4 and §14 both settle that. The absence is deliberate and the skill should state it, so a later reader does not read the gap as an oversight and "fix" it.

12.3 The §12.2 decision, made and recorded (v1.3 / 035)

The draft-returning pipelines_update is a payload change, not a surface change: all 18 tool names stand, no tool is added or removed, and mcp-server.md §6.1 is untouched. Concretely: pipelines_update gains the REQUIRED expected_hash argument (same input as pipelines_create plus id and expected_hash), and both create and update results gain status, body_hash, current_version and — when a draft exists — a draft pointer; pipelines_get merges the same fields into the returned body. §6.2.5's prose and §6.2.2's result note in mcp-server.md were amended to say exactly this (the tool is documented in prose, so McpToolSurfaceSpecDriftTest's schema comparison is unaffected by construction). Making expected_hash required is deliberate: D3 says no last-write-wins EVER, and an optional precondition is a protocol hole an agent will fall through — the refusal for omitting it is the input schema itself.

12.4 The general rule, beyond this spec

Any round that changes what an agent can do, must do, or will be refused for, updates SKILL.md in the same commit. The test is not "did the API change" but "would an agent holding the current skill now be wrong?" If yes, the skill is part of the change set. The skill has no drift guard today — nothing fails when it goes stale — so this rule is carried by review, and a round that touched agent-visible behaviour without touching the skill should be asked why in its handback.


13. Testing Requirements

House discipline: every guard below must be falsifiable — revert the production change and the test goes red (a guard that cannot fail is not a guard).

  • Write-path unit/integration: draft create (copy-on-write honored, released row untouched), draft write (in-place, no new row), release (pointer bumps, statuses flip), discard (delete vs DISCARDED-by-FK). Two concurrent draft creates ⇒ exactly one winner, one conflict.
  • Hash precondition: every mutation with a stale hash ⇒ 409 with current hash/author details; with the correct hash ⇒ succeeds. Falsification: remove the WHERE body_hash predicate and watch these go red.
  • One-draft index: second draft insert violates the partial unique index.
  • Release pin rule: pipeline release with a DRAFT template pin ⇒ pipeline.release.dependency_not_released; with all pins released ⇒ succeeds.
  • Import table (§9.2): each row of the conflict table as a case; hash recompute guard catches a doctored payload; the D60 pointer rule (subsequent import never moves the pointer; first import onto a current-less entity sets it).
  • Promotion guards: draft selected ⇒ pipeline.promotion.not_released; same-version ⇒ pipeline.promotion.not_newer; push order respects the closure (child pipeline absent on target ⇒ parent not pushed); datasource pre-validation fails batch atomically.
  • The lifecycle model test (101's acceptance gate). VersionLifecycleModelTest generates seeded random event sequences (length 1–25) over real Postgres and the real services, in both postures, and after EVERY event asserts the invariants — ≥ 1 version row; ≤ 1 DRAFT; the DRAFT is the highest number; numbers strictly increase; the pointer is live-or-NULL and eligible for the posture; after a pointer move it is the highest eligible; no exact pin from a live version points at a DISCARDED/purged version; the entity status equals the derivation; names unique; a purged version leaves zero execution rows — AND that the event's outcome equals §3.5's row for that shape. The sequences replay §3.5's own rows (parsed, never copied) before the random phase. The gate runs 2,000 sequences per posture with a bounded, measured runtime; the seed prints on failure with a shrunk counterexample. Falsify the model itself — comment out one service rule (e.g. eligibility under hardened) and the property must fail within the run; a model test that cannot go red is a rubber stamp.
  • Drift: new §13.13 codes ↔ PipelineErrorCodes constants. PipelineErrorCodesSpecDriftTest parses the catalogue generically and asserts BOTH directions, with a per-domain non-vacuity guard — so this is enforced automatically. The operational consequence is that the §13.13 rows and the constants must land in the SAME commit: a catalogue row without its constant turns main red immediately, and the drift test lives there permanently. The same applies to the mirrored template.* codes and TemplateErrorCodesSpecDriftTest.

14. Out of Scope (Deliberate)

  • MCP release tool — decided against (D4). The REST release endpoint exists for the UI; an agent holding a raw MUTATE-scoped API key could call it directly, which is acceptable — "not in the agent" means no first-class tool surface. The 101 verbs (purge, discard, restore, entity purge, switch) extend the same ruling: no MCP tools, and REST carries them session-only.
  • UI for the lifecycle verbs — 102 puts buttons on them; this round ships services, REST, the spec and the proof.
  • Bulk "release all drafts" — per-pipeline release only for now; a bulk action can be added to the promotion screen later without schema change.
  • Multi-hop promotion, promotion scheduling, promotion history UI — the single-target, human-triggered flow ships first.
  • Merge/rebase tooling for conflicting drafts — the conflict response gives the current hash; rebase is manual by design at this scale.
  • Discarded-release retention jobs — DISCARDED release rows accumulate only when a human discards one; a retention job is deferred until measured. (The executed-draft tombstones this used to describe are gone — drafts are purged with their executions, §3.1.)

15. Ratified Decisions and Remaining Open Items

Items 1–3 were ratified by the operator on 2026-08-31 and are written into their sections; they are recorded here so a reader sees what was decided and why, without re-opening it.

# Decision Where Reasoning that settled it
1 Metadata rides the release, plus a draft-write-time name-uniqueness check §3.7 The pipelines row is an INDEX over the current released body, not a second copy of the metadata — so indexing a draft would make it an index over a mixture. The early check removes the only real cost (a duplicate-name rename failing at Release rather than at the write).
2 The promotion-peer credential is specified here, as a non-interactive service principal §10.6 §10.1's "API key" is workspace-pinned and user-owned; neither is right for one deployment writing to another. This doc is the credential's authority; the machine-auth note's F10 defers here.
3 Draft runs are derived from released_at, not recorded in a column §8 The lifecycle is one-way and numbers are never reused, so the comparison is well-defined. Its precondition — started_at is application-supplied, so the comparison spans two clocks — is stated in §8, along with the requirement that released_at be database-generated.

Remaining open, deliberately:

  1. The receiver inventory API shape. §10.2 needs the target's per-pipeline (id, current_version, body_hash). rest-api.md owns the endpoint's shape at implementation; this doc owns only what promotion needs from it.
  2. Editor UX for the release action — button placement, the "draft pending release" badge, the conflict-reload flow. pipeline-editor.md's next revision, not this doc's.
  3. Whether the §10.6 service principal later serves the application-execution case the machine-auth note describes. That note settles it once ratified; §10.6 constrains only promotion.

Change Log

Date Version Author Change
2026-10-28 v1.33 #737 parameter-set restore template classification §3.5.3 (the restore recheck paragraph): a parameter-set restore's selector pin that is missing answers template_version_not_found when the template ENTITY is registered in the workspace and template_not_found when it is not — the rule v1.31 stated for pipelines; the version-1 probe the set recheck still used is gone. A template whose every version is DISCARDED is registered; another workspace's template of the same id is not.
2026-10-27 v1.32 #575 wire inventory parity §10.2: the wire inventory obeys the same RELEASED-current-pointer rule in all five families; draft-pointer and never-released objects are omitted.
2026-10-26 v1.31 #733 restore template classification §3.5.3 (the restore recheck paragraph): a pipeline restore's template pin that is missing answers template_version_not_found when the template ENTITY is registered in the workspace and template_not_found when it is not — the entity row, as the template save rule reads it, no longer a version-1 probe. A template can hold a live version while version 1 is absent (a preserved-number import onto an absent entity lands the imported version alone, §9.2; an import above a never-released draft v1 followed by the draft purge leaves the import alone, §5.4), and the probe misreported such a template as absent. Witnessed on the real registry (TemplateVersionProbeIntegrationTest); the parameter-set restore keeps the probe until #737. Code: TemplateIds port, PipelineRestorePins. No route, permission or role changed.
2026-10-24 v1.30 #517 release dependencies, core (S1) §5.3: precondition 2 is the release closure (templates and child pipelines, bounded): no consent / the legacy alias (direct template drafts) / a release plan; the arming rule (LEGACY_ALIAS_WITHOUT_PLAN_ADMITTED, #522); one transaction, one strict audit row per flip; the workspace writer guard over every pin-affecting write. §3.5: the twins paragraph (a plan applies each node's release row; every event incl. both imports runs under the guard; <family>.release.busy); the table row's code renamed. §10.4: the receive batch acquires the guard first. Round three (#719): §3.5.3 a pipeline's and a parameter set's restore re-judge their outbound pins under the guard (missing/DISCARDED → the family validation code, 400; DRAFT → <family>.release.dependency_not_released, 409); §3.1's restore verb and D59 say so.
2026-10-17 v1.29 #563 draft pointer not promotable §10.2 states the rule: an object whose current pointer names a DRAFT (§3.4's development-posture fallback or a manual switch) is not promotable and is invisible to a promoter whatever releases it holds; it returns when the pointer names a release. §3.4 carries the cross-reference. Code: the pointer-row status travels with the four current-version readers and the promotion derivation filters RELEASED itself (the lens, the listing, the counts and the family inventory read one computation).
2026-10-06 v1.28 #462 draft purge pins Both pipeline draft purge entry points refuse named inbound pins before deletion; §3.5 adds draft-pin refusal rows and the dashboard reverse arrow, states the existing parent/dashboard scope asymmetry and residual concurrency window; §5.4 states hash precedence and preflight behavior.
2026-10-06 v1.27 459 (#459) draft dependencies — merge follow-up D58 relaxed (the row was changed by the lane): a draft pipeline may pin a live DRAFT or RELEASED child at save; release and import still require RELEASED children (pipeline_reference_not_released). §3's composition paragraph now says so (it still said RELEASED-only at save). §3 graph rule 3 and the "moot" paragraph no longer rest on "a never-released pipeline cannot be pinned": the pipeline entity purge asks the any-version question (it always ran refuseIfPinned). The pipeline draft purges (purge, purgeVersion) carry no pin guard and now owe one, because a draft parent or a draft dashboard may pin the draft they take. That gap and its traced impact are stated in rule 3 and tracked as #462.
2026-10-02 v1.26 328 (#328) the flip records the caller output §5.3's sketch mirrors the statement: the flip now also sets caller_output_json — the D1 subselect over pipeline_executions (SUCCESS, root, non-null schema, started_at > the last draft write's clock), in the SAME statement, so the record is atomic with the flip and cannot drift from it. A never-run draft releases with NULL (not_observed in the response/audit); the release is never refused for a missing run; the record is outside body_hash. Shape and precedence: pipeline-contract §3.3.1.
2026-10-01 v1.25 #349 the workspace composition — UI wording only §7.2's table, the UI row: the viewed version moves IN PAGE (the selector and the Versions tab apply the admitted body through the REST version read — never a document reload, so an active run keeps its stream and identity), and the client's viewed-version state is the lens-filtered admitted history — a hidden current or draft never enters it. Service/REST/MCP defaults untouched; no route, permission or role changed.
2026-10-02 v1.24 344 (#344) bundled templates are the artifact's pins §10.4 states the rule the sender already followed (the owner's ruling of 2026-10-02): the closure is read at the exact pinned versions without the promoter lens — the lens and §10.3's guards govern the roots; a pinned template the view hides still rides, and the same-version-and-hash skip is the only omission. No behaviour changed.
2026-10-01 v1.23 332 (#332) the three families audit every lifecycle verb §7's blanket sentence extends: the parameter-set, visualization and dashboard families emit their five human-verb events and their <family>.version.released (cascade provenance included), the same LifecycleVerbs.audit/auditRelease twins the pipelines and templates surfaces run; §3.5's table itself is unchanged (the audit column is enums.md §15's).
2026-09-30 v1.22 348 (#348) the version-explicit workspace — renumbered at merge after L1c-c's v1.21 §7.2's table disambiguates the REST GET /api/v1/pipelines/{id} (working version, unchanged) from the UI read workspace GET /pipelines/{id}, whose default is the ACTUAL current pointer (a permitted current draft shows as the draft it is), then an accessible draft, then choose-a-version — never "latest release" (§3.4's sticky pointer stated in the UI's own contract); an explicit ?version=N renders exactly or 404. The workspace's execute POSTs always pin their viewed version, so no UI default decides a run. §3.5's draft-affordance sentence now describes the header's role-gated lifecycle verbs and the viewed-version chip. Service/REST/MCP defaults are untouched.
2026-09-30 v1.21 L1c-c (#10) transfer limits and evidence §10.4: the count ceiling stated as AGGREGATE — max-visualizations-per-dashboard also caps each whole batch arm on top of the per-document bound (two individually valid dashboards can together exceed it; the batch refuses whole, no partial writes, no split; a batch at exactly the ceiling lands whole — E2E-proven both ways). The receive's refused-dashboard rollback witnessed end to end: earlier batch members land, the dashboard refuses on landing, every preceding write (template, visualization, version, current pointer, accepted-audit row) absent.
2026-09-30 v1.20 L1c-b (#10) the transfer round's corrections §10.4: the batch's visualizations arm carries the dashboards' pinned visualizations too (deduplicated against the roots, O1) and the dashboards arm carries dashboards alone; both arms count-bounded by max-visualizations-per-dashboard before the transaction opens (O7); a landing's pins judged by the RELEASE rules — a present-but-not-RELEASED pin refuses the family's release.dependency_not_released (O2).
2026-09-29 v1.19 L1c (#10 L1c) the transfer on the wire — renumbered at merge after 320's v1.18 §10.4: the closure includes the pushed visualizations' transform pins; the receiver's one-transaction order is stated through to the two transfer families — templates → sets → pipelines → endpoints → visualizations → dashboards (D61) — landed inside the transaction, bound through the families' strict readers, the just-landed rows visible to the import lens, a refusal at any entry rolling the batch back whole.
2026-09-30 v1.18 320 (#320) dependency guards §3.5: the reverse arrows into other families. Graph rule 1 names every arrow and its code; new §3.5.3 tabulates them: a visualization's transform.template → template.in_use (referencing_visualizations), a dashboard source → pipeline.version.pinned (referencing_dashboards), a dashboard's parameter_set → the new parameter.in_use. Rule 3: a draft purge that takes the entity is an entity purge; the template and parameter-set DRAFT purges — which ran no pin check — are guarded (the {R,D} purge row was right, the code did not do it). The pipeline purge's exclusive-draft offer skips a draft template a parameter set or a visualization also pins and reports it as kept_draft_templates (it deleted a set's template before). Restore of a DISCARDED dashboard or visualization re-judges its dependencies (D7). The any-version rule's reachability and the residual window are stated.
2026-09-29 v1.17 L1a (#10) visualizations and dashboards §3.5: the tree gains its fourth and fifth families — visualizations and dashboards (V42; the parameter-set shape twice, one generic lifecycle): their rows read with visualization.* / dashboard.* codes; a visualization's release needs a test case, a released (or consented-cascade) transform pin and the D56 evidence gate (refusing until the tests lane installs it); a pinned visualization version is never purged or discarded while a live dashboard version pins it (visualization.version.pinned, graph rule 1); a dashboard's release cascades its DRAFT visualization pins under release_pinned_visualizations in one transaction (D61).
2026-09-28 v1.16 194d (#194) parameter sets — renumbered at merge after 286's v1.15 §3.5: the tree gains its third family — parameter sets (the record's §8.1: the templates' table shape, the pipelines' hash and index semantics, addressed by id, the parameter.* codes reading every lifecycle row). Release carries the pinned-template precondition plus the record's §4 source re-validation; the C14 boot rule adds parameter-set drafts to the authoring-disabled refusal. §5.5's refusal names all three draft families.
2026-09-28 v1.15 286 (#276) §3.3 names the constraint the race loser actually hits: both first writers allocate the same max + 1, so the loser collides on the version PRIMARY KEY before the one-draft index. TemplateRepository.mappingDraftRace mapped only the index and answered a first-draft race with a raw DuplicateKeyException (measured by a forced race — the winner's row held uncommitted until Postgres reported the contender blocked); it now maps both, as PipelineRepository and ParameterSetRepository do. No lifecycle rule changed.
2026-09-25 v1.14 scheduler lane 1 (#9) §3.4: the schedule dependent is #9's (was "092"), follows the pointer wherever it points, drafts included (R5); a NULL pointer makes a schedule record a not_started / pointer_null run and block — "records a refused run" was the draft's wording (scheduler design revision A16).
2026-09-21 v1.13 178 (#178) §10.2's rule is now also the promoter lens (Auth §11A.1): computed once per request (PromotableView) for pipelines AND templates — the template arm stated for the first time — and read by the promotion page and by every promoter read alike; the push path's root guard decides "not newer" from the same object (a same-hash root at a higher number is refused promotion.not_newer with "same content", where the page already hid it). The 2026-09-20 verb ownership corrected in the lifecycle-verbs block: release and switch are the author's (D8), promote the promoter's.
2026-09-19 v1.12 172 (#172) The serving table's published-endpoint row follows the re-rooted URL shape (R-EP5, /api/<category>/<version>/<path…>); the rule — the endpoint serves whatever the pointer names — is unchanged.
2026-09-15 v1.11 142 release cascade §5.3 precondition 2 gains the cascade: release(releasePinnedTemplates = true) — REST ?release_pinned_templates=true, the dialog's checked-by-default consent group — releases every DRAFT template version the draft body pins in the SAME metadata transaction as the pipeline flip, templates first, through the new TemplateReleaser port (pipeline-contract, implemented in web over TemplateReleaseService.releasePinned so the direct and cascaded verbs share one implementation); any refusal rolls everything back. Audit: one template.version.released per cascaded template with cascade_from_pipeline_id / cascade_from_version, then pipeline.version.released with templates_released. The default is the pre-142 refusal byte for byte; its details gain pins_not_released. DISCARDED / MISSING pins stay unreleasable; promotion receive unexposed (§10); no MCP tool releases anything. §3.5.2 gains the {R,D} release(releasePinnedTemplates) row (excluded from the model replay like the template-twin rows; ReleaseCascadeE2eTest owns it).
2026-09-14 v1.10 140 release checks §5.3 gains precondition 4 — the release-check gate: a body carrying checks[] (pipeline-contract §3.3) gets a fresh server run (via = release, one pipeline_check_runs row per check, outside the metadata transaction) before the flip; any fail/error refuses with 409 pipeline.check.failed (details.checks = expected/observed/message per failing check) unless the request carries override_checks_reason (non-blank, ≥ 10 chars), which releases and adds checks_overridden + override_reason to the pipeline.version.released audit event. Checks are opt-in — a check-less version releases exactly as before — and the agent never supplies an observed value: only the server's own run produces observed. §10.5 records that a promoted release's checks travel in the body and the receiver re-runs them on its own datasources at its release step (no override channel; a missing datasource is error, the truth). The gate's placement moved PipelineService.release's transaction boundary: the template-pin guard and the flip still share one transaction; the check runs deliberately run outside it (datasource.lease_in_transaction).
2026-09-13 v1.9 R12 discarded pinners protect §3.5: a DISCARDED pipeline version protects the draft templates it pins, exactly like a live one. The entity purge's exclusive-template offer counted only DRAFT/RELEASED pinners of other pipelines (EXCLUSIVE_DRAFT_TEMPLATES_SQL), so purging pipeline B could take a template that A's discarded version still pins, and D59's "restore always works" would revive a version that cannot run. Owner ruling (b): restore must keep what it runs — the status filter on other pinners is gone (a purged version has no row and never pinned). The direct template purge/discard guard (findAnyVersionTemplatePins) already counted every stored version; its prose said "live" and now says "stored". Found by ExclusiveDraftTemplatesIntegrationTest, the first test of that SQL (the service suite stubs the seam, the web suites stub the model); the table's two template-twin rows reworded, still excluded from the replay by their "(template twin)" marker.
2026-09-11 v1.8 117 templates_update §7.1's MCP-authoring paragraph now names the template twin: templates_update calls the same TemplateDraftService.write as PUT /templates and lands its work as drafts, with the If-Match hash as the required expected_hash argument. No lifecycle rule changed — the agent writes the draft, a human releases (D4).
2026-09-08 v1.7 101 version lifecycle (D57–D60) §3 rewritten as a table-first model: version statuses DRAFT → RELEASED → DISCARDED with the verb pairing purge = the draft verb (row hard-deleted WITH its executions — the §3.4 tombstone rule and every sentence about the executed-draft DISCARDED flip are withdrawn), discard = the release verb (reversible via restore), entity status derived (ACTIVE while any version is live; DISCARDED when all are), names unique forever (D59 — is_deleted soft delete retired in V19, every reader moves to the derived status, pipeline_reference_deleted becomes the derived entity-DISCARDED refusal), and current_version sticky and event-driven (D60 — moves only on release / discard-of-current / restore-above-current / manual switch / purge-of-current-draft; picks the highest ELIGIBLE live version; DRAFT eligible only under development posture; import never moves an existing pointer except the first import onto a current-less entity). New §3.5 "The lifecycle table": ~67 rows over ten version-set shapes × events × postures with an inbound-edge column — the ruling, parsed by VersioningSpecDriftTest so the model test runs the DOC's rows. New decisions D57 (≥ 1 version row; last_release refuses only the purge path on a release), D58 (PIPELINE nodes pin RELEASED children only — pipeline.validation.pipeline_reference_not_released at save), D59, D60. §5.4 is now the draft purge (executions deleted in-transaction, Redis keys expire on TTL); §7's REST table gains the 101 verbs (all MUTATE_PIPELINES_TEMPLATES, all session-only, all audited — pipeline.version.discarded/restored/purged, pipeline.purged, pipeline.current_switched, template twins); §9.2's import table drops "bump if greater" for the D60 pointer rule, and index metadata now rides the pointer, not the number. §13 gains the model-test acceptance gate. Old §3.2/§3.5 became §3.6/§3.7.
2026-09-08 v1.6 099 draft-first (D55/D56) §3.2 rewritten. Creation lands version 1 as a DRAFT for pipelines and templates alike, and pipelines.current_version / templates.current_version are NULL until a human releases (V18 makes them nullable and drops DEFAULT 0; the two import readers that did arithmetic on the pointer read it through COALESCE(…, 0)). The old sentence — "creation is not a modification … lands version 1 directly as RELEASED, so an MCP-authored pipeline is executable the moment it is created" — was an ASSUMPTION written 2026-08-31, never ratified, contradicting D4, and its only justification has been false since 039 made drafts executable. New §7.2: execute with no version runs the WORKING version (draft if one exists, else the latest release), resolved in ONE place (PipelineService.workingVersion) for REST execute, pipelines_execute and the MCP body resource; released-only surfaces (promotion candidates and inventory, published endpoints, the export bundle) are unchanged and now say so. §3.4 records the nullable pointer, the working-version reads and the one "no version at all" state (discard of the sole draft of a never-released pipeline). §7's REST rows updated; new decisions D55 (create lands DRAFT, D4 without exception) and D56 (execute runs the last version; hardened deployments hold no drafts by construction). The non-authoring create paths — promotion import and the seeders that ride it — still land RELEASED, now as a required CreateLifecycle argument rather than an implicit default.
2026-09-01 v1.5 039 lifecycle loose ends §5.1: the no-op guard — a draft-create whose body is identical to the released one is suppressed, compared hash-to-hash in the same statement by the canonical-hash expression itself; the no-op answer returns the current RELEASED state (not a 4xx, no draft pointer), both arms join the guard so a stale hash still 409s, a no-op never answers "no draft" while a draft exists, and a draft edited back to its released parent is left alone (never auto-discarded). Templates mirror it, with index metadata still moving on a content-identical save. Draft-existence becomes a truthful "content genuinely differs" signal. §7.1: authoring reads (REST GET, pipelines_get/templates_get, the editors) return the working version — the draft if one exists, else current_version — and state which version/status they returned; explicit version still wins, current_version keeps meaning latest-released (working version is derived, no schema change). §5.5: authoring is a deployment capability — the datapipelines.deployment block (name a LABEL nothing branches on, pinned by a guard test; authoring-enabled default true; the promotion sub-block reserved via §10.6's fenced sample) — enforced fail-closed at the write path with pipeline.authoring.disabled / template.authoring.disabled (§13.13/§13.9); import never creates drafts and is never refused; boot logs the deployment posture, WARNs on the receiver-also-authors combination (currently one-sided — the promotion half slots in when promotion ships) and refuses to start when a disabled-authoring deployment holds existing drafts.
2026-09-01 v1.4 035 implementation The implementer's amendments, landing with the code. §4.1: canonicalization pinned mechanically — the hash is computed BY THE DATABASE (encode(sha256(convert_to(<jsonb>::text, 'UTF8')), 'hex')) over the JSONB projection (pipelines) / the jsonb_build_object field object (templates), one expression shared by V6's backfill and every write; the serializer string is the write format, not the hash anchor. §3.4: draft allocation is max(existing)+1 — a DISCARDED number is consumed, so the pointer alone would collide. §5.3: the sketch's updated_at = NOW() on the flip is dropped; §11's column note governs (draft-write metadata, never restamped). §6: template metadata asymmetry recorded (display_name/description are index-row, save-time; template discard always hard-deletes — no FK can block it). §7: exact REST spellings — If-Match header on PUT/release/discard, pipeline.version.not_draft vs *.version.conflict distinction, draft_run field name; §9.2: wire spelling (body_hash required with version, released_at honored, released_by = importing actor, template hash-mismatch gap surfaced as template.version.conflict + reason, raised not papered); §12.3 records the §12.2 decision (payload-only MCP change; expected_hash required on pipelines_update; mcp-server.md §6.2.2/§6.2.5 prose amended).
2026-09-01 v1.3 operator ratification §10.6 replaced: the promotion credential is a pre-shared server key, not a principal. The receiver holds a promotion server key and refuses when it is absent (fail closed); the sender holds the same secret with the target URL and presents it on the promotion call. No service account, no scope-matrix entry, no users row for the credential — promotion is a deployment trusting a deployment, and the earlier service-principal draft was more machinery than the problem needs. Records the one gap the shape does not close: created_by/triggered_by are NOT NULL FKs to users, so an imported row still needs a local actor; three options given, a single reserved non-interactive row recommended, awaiting ratification. F10 defers here; F2's service-account question stays with the machine-auth note, because an application EXECUTING a pipeline is a different problem from a deployment PROMOTING one.
2026-08-31 v1.2 operator request New §12: the agent-facing skill (.agents/skills/datapipelines/SKILL.md) is updated in the SAME commit as the behaviour it describes. Concrete for this spec — SKILL.md:51 says "every save creates a new version", which D2 makes false, so an agent holding the current skill would believe a pipelines_update published something it left as a draft. Enumerates what this round obliges (versioning concept, golden path stopping short of release, draft execution, the new 409s, the hash protocol, the references list), what the implementor must DECIDE rather than assume (whether the draft result reshapes the MCP tool surface, which mcp-server.md and McpToolCatalog own), and the general rule: the test is "would an agent holding the current skill now be wrong?" Notes that the skill has no drift guard, so the rule is carried by review. Sections 12–14 renumbered to 13–15.
2026-08-31 v1.1 operator ratification §15's first three open items decided and written into their sections. §3.5 rewritten: the pipelines row is an INDEX over the current released body — the metadata is not duplicated, one side is the artifact and the other is how you find it — so metadata rides the release by definition rather than by preference, plus a draft-write-time uniqueness check reusing pipeline.validation.duplicate_name (no catalogue addition). New §10.6 specifies the promotion-peer credential as a non-interactive service principal, scoped to the import endpoint, backed by a real users row because created_by/triggered_by are NOT NULL FKs; this doc is its authority and the machine-auth note's F10 defers here. §8 drops the ran_draft column for derivation from released_at, with its cross-clock precondition stated (started_at is application-supplied) and released_at required to be database-generated.
2026-08-31 v1.0 orchestrator review Review pass before commit. Corrected §3.4: the executions FK is NO ACTION (its declaration carries no ON DELETE clause), not RESTRICT — it blocks identically here, but an implementer reading the old wording would have written the wrong DDL. §13 (Testing Requirements) now states the operational half of the drift coupling: catalogue rows and constants land in the SAME commit, because the drift test lives on main permanently. Also grouped the doc into DocsCatalog "Contracts" — 033's in-app docs index fails at init on an ungrouped doc, so the spec could not land without it. Verified against the tree: the import-renumbering defect (§9.1), PipelineImportService.SERVER_FIELDS, the executions composite FK, and the absence of status/body_hash today.
2026-08-31 v1.0 versioning design session Initial spec: draft-in-version-table lifecycle (D1/D2), content-hash preconditions (D3), UI-only release (D4), version numbers as global identities + preserved-version import (D5), latest-released-only promotion with two-sided guards (D6/D7/D8). Records the verified import-renumbering defect as D5's rationale.

This is the documentation packaged with the running version. The same files live in the repository on GitHub.