Skip to content

Publishing & Versioning

POST /workflow-definition/publish is the one endpoint for getting a workflow document into the engine, whether that is the first version ever, a new version alongside existing ones, or the exact same document you published a minute ago. It is also the only one: the older create/update routes are gone (see The old routes are gone).

TL;DR

Send one JSON body: the workflow document under workflow, the options beside it. The engine compares the document against the version it would displace and computes a floor – the smallest semver bump the detected changes honestly justify. Declare at least that much or the publish is refused with 422 BelowFloor. Republishing an identical document is a no-op (200 unchanged), so re-running a publish step in CI is always safe. A published version is immutable – its content is never replaced, and an occupied version answers 409. "increment": true picks the next free version instead of failing; "dryRun": true runs the whole analysis and writes nothing.

The Request

jq '{workflow: .}' workflow.json | curl -X POST http://localhost:3030/workflow-definition/publish \
  -H "Content-Type: application/json" \
  -d @-

The body is one object: the workflow document under workflow, and the options below beside it. There are no query parameters. For the document itself, everything under Definition applies: label, name, package, the three version fields, seedEntity, type, steps, and the optional root fields.

{
  "workflow": { "type": "workflow", "package": "wf.acme", "name": "backup", "...": "..." },
  "dryRun": true
}

YAML in, JSON out

The endpoint accepts JSON like the rest of the API. Convert and wrap a YAML source file in one line: yq -o=json workflow.yaml | jq '{workflow: .}' – see Your First Workflow for other conversion options.

Options

Field Type Purpose
increment boolean The declared version is taken. Publish as the next free version at the floor’s level instead of failing. When the declared version is free, it is written unchanged and the floor still applies.
dryRun boolean Run the whole analysis and write nothing. Always returns 200 with the outcome it predicts, whatever that outcome is.
acceptBump "patch" | "minor" Publish below the floor deliberately, naming the bump the declared version actually expresses. Under increment, it chooses the level to assign instead of using the floor.

There is deliberately no option that replaces the content of an existing version. A published version is immutable: the coordinate is the contract, and the only answer to an occupied one is a different version number.

Booleans are booleans

The options are JSON fields, so true means true and "true" the string is a type error, refused with 400 before any analysis runs – nothing is leniently coerced or silently guessed at. An unknown field (such as the departed force) is stripped and ignored.

Responses

201 Created – published

The definition was written. The id in the body identifies the published version.

{
  "id": "wf.acme/backup:1.3.0",
  "version": "1.3.0",
  "status": "published",
  "bump": "minor",
  "floor": "minor",
  "findings": [
    {
      "code": "STEP_ADDED",
      "path": "/steps/verify_backup",
      "message": "Step 'verify_backup' was added.",
      "level": "minor",
      "target": { "kind": "step", "label": "verify_backup" }
    }
  ]
}

bump and floor are null on a first-ever publish, a new major, or a version below every existing version of its major – there is nothing displaced to compare against. 201 also covers an identical-content republish reviving a soft-deleted version, which adds "revived": true to the body – it never rewrites what the tombstone holds. (POST /workflow-definition/{id}/restore clears the same tombstone by ID, no document needed.)

Reviving a tombstone is never "unchanged"

If the declared version was soft-deleted and the document you send is byte-identical to what it held, publishing still returns 201 with "revived": true rather than 200 unchanged. Restoring a tombstone changes what the engine resolves and what the API lists, which is a real change even when not one byte of the document is.

200 OK – unchanged

The declared version already holds exactly this document; nothing was touched.

{
  "id": "wf.acme/backup:1.3.0",
  "version": "1.3.0",
  "status": "unchanged",
  "bump": null,
  "floor": null,
  "findings": []
}

This is what makes re-running a publish safe: publish the same document at the same version twice, and the second call is a 200 no-op rather than a conflict.

200 OK – dry run

With "dryRun": true, the response is always 200, whatever it predicts – an analysis that correctly predicts a rejection is still a working analysis, and returning an error status would make “the analysis failed” and “the publish would fail” indistinguishable to a generated client.

{
  "dryRun": true,
  "wouldStatus": "BelowFloor",
  "version": "1.3.1",
  "bump": "patch",
  "floor": "minor",
  "declaredBump": "patch",
  "suggestedVersion": "1.4.0",
  "findings": [
    {
      "code": "STEP_ADDED",
      "path": "/steps/verify_backup",
      "message": "Step 'verify_backup' was added.",
      "level": "minor",
      "target": { "kind": "step", "label": "verify_backup" }
    }
  ],
  "wouldRevive": false,
  "wouldExceedLicense": false
}

wouldStatus is one of the two success statuses (published, unchanged) or any rejection code below, including LicenseLimitExceeded – which on the real path is raised by a different layer, after this analysis, but a dry run reports it here so the prediction is complete.

400 Bad Request

The request names something unknown, or the document cannot be published at any version.

error When
AcceptBumpMismatch acceptBump names a level other than the one the declared version actually expresses.
InvalidParameter acceptBump names something other than patch/minor. (A boolean option that is not a real JSON boolean is refused by request validation before this table applies.)
InvalidDocument The workflow itself can’t be published – currently: a step label used more than once.
{
  "statusCode": 400,
  "error": "AcceptBumpMismatch",
  "sent": "patch",
  "expected": "minor",
  "retryWith": { "acceptBump": "minor" },
  "message": "Version 1.3.1 is a minor of 1.2.0, but acceptBump named 'patch'.",
  "hint": "Set acceptBump to 'minor' to publish this version below the floor, or declare a version that is a minor."
}

409 Conflict

The declared coordinate is already taken. Published content is immutable, so the recovery is always a different version number – there is no flag that turns either conflict into a replacement.

error When Recovery
VersionAlreadyPublished The version exists, live, with different content. Publish a new version number (suggestedVersion in the body is a free one), or resend with "increment": true.
VersionDeleted The version exists as a tombstone and the document differs from what it held. Restore it unchanged via POST /workflow-definition/{id}/restore (or republish the original content), or publish this document as a new version.
{
  "statusCode": 409,
  "error": "VersionAlreadyPublished",
  "suggestedVersion": "1.3.1",
  "findings": [],
  "message": "Version 1.3.0 is already published and this document differs from it.",
  "hint": "Publish 1.3.1 instead — a published version is immutable, its content is never replaced."
}

suggestedVersion is the next free number at the floor’s level, tombstones counted – publish that (or resend with "increment": true) rather than deriving your own, which can land on a tombstone or under the floor.

422 Unprocessable Entity

The document is publishable; the declared version number doesn’t describe the change honestly.

{
  "statusCode": 422,
  "error": "BelowFloor",
  "floor": "minor",
  "declaredBump": "patch",
  "suggestedVersion": "1.4.0",
  "findings": [
    {
      "code": "STEP_ADDED",
      "path": "/steps/verify_backup",
      "message": "Step 'verify_backup' was added.",
      "level": "minor",
      "target": { "kind": "step", "label": "verify_backup" }
    }
  ],
  "retryWith": { "acceptBump": "minor" },
  "message": "The changes in this document are at least a minor, but the declared version is only a patch.",
  "hint": "Publish 1.4.0 instead, or resend with acceptBump 'minor' if the minor is not real."
}

MajorRequiresExplicit is the other code here: under increment, if the floor comes out at major, the endpoint refuses rather than silently assigning a new major version – that stays a human decision. Its retryWith is null; there is no flag that fixes it, only a different request.

403 Forbidden – license limit

Publishing a genuinely new (package, name) when the license’s workflow-definition cap is already reached:

{
  "statusCode": 403,
  "error": "LicenseLimitExceeded",
  "message": "Developer Preview: workflow definition limit reached (5/5).",
  "limit": { "name": "workflowDefinitions", "max": 5, "current": 5, "countingRule": "by-package-and-name" },
  "hint": "Soft-delete an unused workflow with DELETE /workflow-definition/{id} to free a slot. New versions of a live (package, name) workflow do not consume a slot.",
  "edition": "developer-preview"
}

Publishing another version of a workflow that is still live never consumes a slot – only a publish that adds a live (package, name) pair does: a brand-new workflow, or a revive/restore of one whose every version was soft-deleted (the delete freed its slot, so bringing it back re-claims one).

The Version Floor

The floor is the smallest semver bump the engine can prove your changes require, computed by comparing the document you’re publishing against the version it displaces.

A floor, not a verdict

The engine can prove that structure changed. It can never prove that behaviour did not. Detection only ever raises the minimum – if your change is riskier than the floor says, declare the higher version yourself; nothing here catches that for you.

What it checks

Root-level fields:

Field Floor
runOn, seedEntity major
acquire, assert, continueOnError minor
condition, cron, delay, description, hideForExecution, parameters, rawParameters, repeatConfig, retryConfig patch

Step-level fields (matched by label, so relabeling a step is reported as removing one and adding another):

Field Floor
runOn major
functionBlock, workflow (the step’s pin) see below
acquire, assert, continueOnError, strategy minor
condition, delay, description, parameters, rawParameters, repeatConfig, retryConfig patch

Structural changes, checked separately from the field tables above:

Change Floor
A step is removed, or moves to a different position in the step tree major
A step’s functionBlock/workflow pin now points at a different target, or moves to another major version major
The input schema (parameterSchema) now requires something it didn’t, rejects a type it used to accept, or stops accepting properties it doesn’t declare major
A step is added, or steps within the same container are reordered minor
A step’s pin is tightened (1.21.2.3) or moves to another version within the same major minor
The input schema gains an optional property, drops a requirement, or accepts a type it didn’t before minor
parameterSchema is added where there was none, or removed entirely minor
Anything else the engine doesn’t recognize minor – an unclassified change never silently passes as a patch

A text pattern (pattern in JSON Schema) is the asymmetric case: adding one is minor (a new constraint on what callers may send), removing one is advisory (strictly more is accepted), and one that merely changed is advisory too – the checker can’t prove whether a different regex accepts less, so it never raises the floor on its own.

The input schema protects the client contract, not engine execution

parameterSchema findings are about the execute form and API callers. The engine validates each step’s parameters against that step’s function block schema and never re-checks the workflow’s own parameterSchema at run time – so a schema change is a promise you’re keeping to callers, not a change the engine itself enforces.

Every finding, including advisory ones, is returned in findings on every response that has them – successes, conflicts, and rejections alike – so you can see exactly what the engine compared.

acceptBump

"acceptBump": "patch" or "acceptBump": "minor" publishes below the floor on purpose. It has to name the bump the declared version actually expresses – sending "acceptBump": "patch" for a version that is a minor bump is refused with 400 AcceptBumpMismatch, telling you the value it expected. major is never an accepted value: a major is never something you accept below a floor, it is the floor, so declare it as an explicit major version instead.

Every 422 BelowFloor (and the matching AcceptBumpMismatch) carries retryWith, the exact fields that, merged into the same body, would make the request succeed – never derive that override yourself from the floor and the declared version; let the server hand it to you. A backport whose floor came out at major additionally carries alternativeVersion, the next free number on the line you were patching, so accepting the floor’s suggestion is not the only way to keep the line alive.

A publish accepted below the floor confirms it: the 201 body carries acceptedBump, so a client can tell an applied override apart from one that was silently unnecessary.

Immutability

A published version’s content is never replaced – there is no option, header, or request that does it. The version coordinate is the contract: anything that ever resolved wf.acme/backup:1.3.0 got, and will always get, the same document. That is also what makes execution history trustworthy – a completed run points at the definition it actually ran.

Two practical consequences:

  • Fixing a bad publish is publishing the fix as the next patch ("increment": true does the numbering for you). The bad version can be soft-deleted so nothing resolves to it any more; its number stays taken.
  • A soft-deleted version can only be revived with its original content. Republishing the identical document clears the tombstone (201, "revived": true), and POST /workflow-definition/{id}/restore does the same by ID with no document needed. A different document on that coordinate is 409 VersionDeleted – publish it as a new version instead.

increment

"increment": true on an already-taken coordinate publishes the next free version at the floor’s level instead of failing outright. It still enforces the floor, measured against the live head of the line: acceptBump chooses the level to assign instead of the floor if you set it, and MajorRequiresExplicit is what you get instead of a silently-assigned new major version.

CI Recipe

jq '{workflow: .}' workflow.json | curl -X POST http://localhost:3030/workflow-definition/publish \
  -H "Content-Type: application/json" \
  --fail-with-body \
  -d @-

--fail-with-body makes curl exit non-zero on any 4xx/5xx while still printing the response body, so a failed pipeline step shows why – the floor it hit, the version it suggests, the exact retry. Because republishing an identical document returns 200 unchanged rather than an error, this step is safe to run on every merge to the branch that owns a workflow’s source of truth, whether or not the content actually changed.

The old routes are gone

POST /workflow-definition (create) and POST /workflow-definition/update have been removed; both answer 404. /publish is the only write.

If you still have a client on the old shapes:

Old routes /publish
Body {"workflow": { ... }} envelope {"workflow": { ... }} again – with the options (increment, dryRun, acceptBump) beside the document instead of on the URL
Publish next free version POST .../update with incrementVersion: true "increment": true in the body
Replace existing content POST .../update with forceOverride: true Nothing – published versions are immutable. Publish a new version.
Version safety None – any version number was accepted Refuses a version that bumps by less than the floor