# 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](/docs/pipeline-contract.md) (§13 error catalog, §17 persistence), [Templates](/docs/templates.md), [Metadata DB](/docs/metadata-db.md) (§4.4/§4.5/§4.8/§4.9 — DDL authority), [REST API](/docs/rest-api.md), [Pipeline Editor UI](/docs/pipeline-editor.md)
**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](/docs/auth.md#11a-roles)).
- **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):

```sql
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](/docs/scheduler.md#5-runs-states-and-reasons));
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](/docs/dashboards.md), 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](#353-the-reverse-arrows-into-other-families-320).
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:

```sql
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`:

```json
{
  "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](/docs/metadata-db.md#46-pipeline_executions) 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)

```sql
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)

```sql
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:

```sql
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](/docs/pipeline-contract.md).)*

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](/docs/configuration.md#335-release-dependencies): 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](/docs/rest-api.md#510-release-pipeline)). (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](/docs/pipeline-contract.md) 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[]`](/docs/pipeline-contract.md#33-release-checks-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.

```sql
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](/docs/configuration.md#319-deployment); the codebase groups config
one-per-concern — `auth`, `executor`, `staging` — so `deployment` is the house shape):

```yaml
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](/docs/configuration.md#7-config-validation)): 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](/docs/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|restore|current` (name + version in the body) and `DELETE /api/v1/templates` (the entity purge). |

**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](/docs/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](/docs/configuration.md#319-deployment),
not restated here, per that doc's sole-authority rule). Shipped by 055; the receiver's two
endpoints are specified in [REST API §18](/docs/rest-api.md#18-promotion-endpoints-receiver).

### 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](/docs/rest-api.md#181-promotion-inventory)) 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](/docs/auth.md#11a1-the-404-rule)).** 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](/docs/rest-api.md#181-promotion-inventory)) 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](/docs/auth.md#11a1-the-404-rule)'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](/docs/configuration.md#319-deployment)**, which is the operator-facing
authority; the sample below is retained as this section's own illustration:

```yaml
# 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](/docs/auth.md#45-the-system-service-account-r7).

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](/docs/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](/docs/auth.md#11a1-the-404-rule)): 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. |
