# Workflow Versioning

> Applies to: ChengOS v0.1.0+ ｜ Last verified: 2026-08-12 ｜ Source: `crates/cheng-core/src/domain/workflow.rs`, `crates/cheng-core/src/workflow_file`, `crates/cheng-api/src/rest/handlers/workflow_file/mod.rs`, `crates/cheng-common/src/version.rs`

Three different things in ChengOS are called a "version", and conflating them causes real
confusion:

| Version | Applies to | Changes when |
|---|---|---|
| Workflow version (`u32`) | One workflow | You save a change |
| File schema version | The `workflow.json` **format** | The format itself evolves |
| ChengOS release version | The product | A release is tagged |

This page covers the first two. For the third, see
[Upgrades & Rollback](/docs/deploy-upgrade-rollback).

## Workflow version and change notes

Every workflow carries a `version` counter starting at 1, and an optional `change_note`. Saving
an edit increments the counter; the change note records why.

The counter is **author-visible metadata**, not a snapshot store. ChengOS does not keep a
history of previous graph revisions you can roll back to from the editor. If you need that, the
answer is the file contract below plus your own version control — which is exactly what the
file contract was designed for.

## Lifecycle state

Separate from version, a workflow has a state:

| State | Meaning |
|---|---|
| `draft` (default) | Being edited; not intended for production traffic |
| `active` | Executable, the normal running state |
| `archived` | Retired but retained |

And a visibility: `private` (default), `public`, `unlisted`, or `shared`.

Promoting `draft` → `active` is the closest thing to "publishing a version" the editor has.

## The workflow file contract

A workflow can live as a `workflow.json` file — the format the frontend writes, the LLM
generator produces, explicit import consumes, and the filesystem watcher reads.

```json
{
  "schemaVersion": 1,
  "workflowId": "fead748f-…",
  "name": "my-workflow",
  "description": "…",
  "state": "draft",
  "version": 3,
  "tags": ["generated"],
  "visibility": "private",
  "definition": { "nodes": [], "edges": [], "notes": [], "containers": [] }
}
```

This external contract is deliberately distinct from the internal domain shape and from the HTTP
DTOs. Three rules govern it:

1. **Identity and tenancy are never read from the file.** `workspaceId`, `tenantId`,
   permissions, favourite/template flags, computed counts, and timestamps come from the
   authenticated import context and the directory layout — not from an untrusted file body. A
   file cannot smuggle itself into another workspace.
2. **Unknown top-level keys are ignored.** A file exported by an older server that still carries
   `nodeCount`, `createdAt`, or `isDraft` decodes cleanly.
3. **`workflowId` is optional only for a brand-new file.** Omit it and the importer allocates
   one; every file the server encodes carries an id.

Because identity is trusted context rather than file content, a `workflow.json` is safe to
commit to Git, review in a pull request, and diff between branches. That is the practical
version-history story for workflows.

## File schema versions and upgrades

`schemaVersion` is the format's own version. Only V1 exists today; a legacy nested envelope with
no `schemaVersion` decodes as V1 and is canonicalised on the next encode.

The upgrade policy is the interesting part:

> A passive watcher may **decode** an older supported version, but it must not silently rewrite
> a user's already-versioned file merely because the server was upgraded.

Rewriting is an explicit operation. `upgrade_to_latest` decodes (validating the graph shape),
re-encodes canonically, and reports `from_version`, `to_version`, and whether the on-disk bytes
would actually change. Future `V1 → V2` transformations go there as explicit steps rather than
being scattered across serde aliases.

So upgrading your server never rewrites your workflow files behind your back. Your Git diff
stays clean until you deliberately import or write back.

## Content hashing

Change detection uses a **normalised content hash** (`sha256:…`) rather than the `version`
counter or byte equality. The hash is identity-independent: `workflowId` is excluded, so it
describes the graph, not the installation.

This is what makes template sync's three-way comparison possible — see
[Workflow Templates](/docs/editor-templates) — and it is why a workflow whose `version` field
never changes still produces a correct "changed / unchanged" answer.

Server-managed metadata keys record the file relationship: `META_FILE_HASH`,
`META_FILE_SCHEMA_VERSION`, `META_FILE_SOURCE`, `META_FILE_MANAGED`, `META_FILE_IMPORTED_AT`,
plus template-origin keys for workflows installed from the catalog. These are server-owned and
are stripped from the file contract.

## Import: synchronous vs the watcher

Two paths bring a file into the database, sharing one importer:

- **The filesystem watcher** picks up changes asynchronously under the workspace root.
- **The import API** runs the same importer synchronously, so a frontend or LLM that has just
  written `workflow.json` gets an immediate structured result instead of waiting.

Import errors are stable machine-readable codes mapped to HTTP statuses:

| Code | Status |
|---|---|
| `WORKFLOW_WORKSPACE_CONFLICT`, `WORKFLOW_SOURCE_CONFLICT`, `WORKFLOW_ID_MISMATCH`, `WORKFLOW_FILE_ADOPTION_REQUIRED` | 409 Conflict |
| `WORKFLOW_NOT_FOUND`, `WORKSPACE_NOT_FOUND`, `WORKFLOW_FILE_NOT_FOUND` | 404 Not Found |
| `IMPORT_INTERNAL_ERROR` | 500 |
| `INVALID_WORKFLOW_FILE`, `UNSUPPORTED_*`, `WORKFLOW_FILE_NOT_STABLE`, `WORKFLOW_FILE_PATH_INVALID` | 400 Bad Request |

`WORKFLOW_FILE_NOT_STABLE` deserves a note: it means the file was still being written when the
importer read it. Retry; do not treat it as a validation failure.

## The ChengOS release version

For completeness: every Rust component reads one release version from `cheng-common`, resolved
at compile time from `CHENGOS_VERSION` (set by CI from the immutable Git tag), the nearest
`VERSION` file, or the workspace package version — in that order.

Components must **not** read their own `CARGO_PKG_VERSION`: that is a per-package build detail
that can drift from the release identity. The canonical form is a bare SemVer (`0.1.0`); the
`v` prefix belongs to Git tags and release asset names only.

## A practical workflow

1. Keep production workflows in `active` state; do experimental work in a `draft` copy.
2. Use `change_note` on every save — it is the only per-save annotation the system stores.
3. Export `workflow.json` and commit it. Git gives you the history, blame, and rollback that the
   editor does not.
4. Re-import explicitly when you want a committed version to become the running one.

## Next steps

- [Workflow Templates](/docs/editor-templates) — content hashing applied to built-in workflows.
- [Upgrades & Rollback](/docs/deploy-upgrade-rollback) — versioning the product itself.
- [Development Setup](/docs/dev-setup) — working with workflow files on disk.
