docs / reference
Version compatibility
Compare two versions of a process before deploying it — what the check catches, what it cannot see, and why there are two verdicts rather than one.
An instance pins the definition version it started on, and keeps it for life. That is what
makes a running process predictable — but it also means a fixed URL, a new header or an
optional input parameter strands every instance already in flight until it finishes. For a
process parked on an external task, that can be weeks.
genctl compat compares two versions and reports what changed, so a change that would
strand running work is visible before it is deployed rather than after.
Comparing two versions
genctl compat <process> <from-version> <to-version>
genctl compat --from stable --to latest [<process>]
genctl compat --from boss@1 --from kid@1 --to boss@2 --to kid@3
genctl compat -f ./processes/*.yaml --from latest
--from and --to are repeatable. A side is either one channel or one-or-more
name@version pins, where the version may itself be a channel (kid@stable). Mixing the
two on one side is refused: a channel already names a version for every process, so adding
one entry is ambiguous about whether it overrides or extends.
invoice v1 → v4 breaking: upgrade, contract
charge:fetch.result (breaking: contract)
amount: number → string
ship (breaking: upgrade)
outputs.charge.amount: number → string
order_pipeline v2 unchanged
A verdict is grouped by what happened, and names the checks it happened to — so
breaking: upgrade, contract when one edit fails both. A comma separates checks, a
semicolon separates outcomes, and problems come first: a colon after the versions is the
whole scan. A process with nothing to look at carries none.
Both checks are always accounted for, so a passing one is named rather than left to
inference — breaking: contract; upgradable says the upgrade check ran and passed. The
versions sit beside the name, with an arrow only where two of them were actually compared.
upgradable | every state an instance can be parked in fits the new definition |
compatible | callers and parents still get the shape they were written against |
breaking: <checks> | they do not; the rows name where |
ignored: <checks> | it broke, and --ignore contract excused it from the exit code |
Under each process, one row per addressable place that was compared, annotated with what
became of it — (ok), (breaking: upgrade), (breaking: contract), or (not judged). A
place that holds up still gets a row, so you can see what was examined rather than inferring
it from silence. The findings sit under it: a path inside that schema, and what differs.
Rows use the same grammar, so one place failing both questions prints once as
(breaking: upgrade, contract), and a check excused by --ignore contract reads
(ignored: contract) on the rows where it was excused — a run that fails anyway shows you
which break is the one still gating. Unlike the process line, a row speaks only for its own
address: a check missing from it broke nowhere here, which says nothing about elsewhere.
An address is one of four things:
input | the process input schema |
output | the process output |
<task> | the context an instance parked at that task holds — paths under it read outputs.charge.amount |
<task>:<slot> | a definition slot on that task: output, switch, only_once, action.type, fetch.url, child.name, <action_type>.result |
The example above is one change reported at two addresses, which is the point of addressing
rather than listing schemas. charge’s result_schema narrowed a field to a string: at
charge:fetch.result that is a contract break, because a parent collecting that task was
written against a number; at ship it is an upgrade break, because an instance parked
there already holds outputs.charge.amount as a number and the new definition will not take
it. Same edit, two different parties harmed, two rows.
unchanged (both sides resolve to the same version, or a submitted document is
identical to what is deployed) and new (only the target side carries it, so nothing is
running it) are statuses rather than verdicts — see what a missing counterpart
means.
The second form is the one to reach for before a deploy: it compares the documents apply
would take against what is currently on a channel, so you see the answer without writing
anything. Every form names both sides — defaulting one would hide which two documents
were compared.
compat exits non-zero when anything is incompatible, so it drops straight into a
pipeline.
Definitions submitted with -f are validated first, exactly as genctl validate does.
A document that does not parse, whose expressions do not type-check, or whose child
references do not resolve is refused with that error — naming the task and the expression —
rather than compared and reported as unanalysable. Stored versions are not re-validated:
they passed under the rules of their day.
That validation is also where a submitted parent’s result_schema is checked against the
child it resolves to, which is the check worth having when a bundle deploys a parent on its
own.
A comparison covers the graph, not one process
Whatever you name is closed over the child versions those definitions were registered against, so a parent is never judged without the children it calls:
genctl compat order_pipeline 2 3
…resolves the from side to order_pipeline@2 plus whatever child versions it was
registered with, and the to side to order_pipeline@3 plus its own. A pin you write
yourself wins over one a dependency supplies. Nothing is hidden — the report prints a line
per process, so a child pulled in shows up with its own versions even though you only
named the parent.
This matters because without it a parent compared alone produces no parent/child rows and reads as clean, which is indistinguishable from a genuine all-clear.
What a missing counterpart means
Each side resolves to a table of one version per process, and the two are reconciled:
on from | on to | outcome |
|---|---|---|
| you named it | present | compared |
| you named it | absent | error — you asked about it and there is nothing to answer with |
| it came along | absent | carried over at its current version; the row reads nothing to compare |
| absent | either | new process — no previous version exists, so nothing is being upgraded |
The asymmetry is deliberate. Naming a process and getting silence is a mistake worth catching; a process that came along for the ride and is absent from your bundle is simply not moving — and carrying it over is what keeps the parent/child check working when a bundle names a parent but not the child it calls.
A process that resolves to the same version on both sides also reads nothing to compare: comparing a document with itself is a tautology. In a channel-to-channel report
that is most of the rows.
A submitted document has no version, so the numbers cannot answer that — its content is
compared against what is deployed instead, and an unmoved bundle reads unchanged
rather than as an upgrade of nothing. Two stored versions are left to their numbers, which
say more than their documents do: a version also pins the child versions it runs, so two
identical documents at different versions are genuinely different processes.
Neither nothing to compare nor new process can break anything, so neither drags the
top-level verdict down. Only real incompatibilities — and versions whose own inference
fails, reported as unanalysable — do that.
The two checks run in opposite directions
They are separate columns (upgrade and contract in the JSON) because they are genuinely
different questions, about different parties:
- Upgrade — could an instance running the old version carry on under the new one? Every state it can be parked in must fit. This is about rows this deployment already owns.
- Contract — does the new version still honour what its consumers were written against?
Here the direction reverses: a parent’s
result_schemaand an API caller were written against the old shape, so every value the new version can produce must be one the old one could. This is about parties outside the deployment.
That asymmetry is why only one of them can be waived. --ignore contract excuses the
contract check from the exit code — for when the output changed shape, nothing running is
affected, and you have already dealt with the consumers. There is no equivalent for the
upgrade check: nobody outside can have dealt with an instance parked mid-run.
genctl compat --from stable --to latest --ignore contract
It changes neither what is compared nor what is printed. The break is still reported and
still rendered, marked breaking (ignored); only the exit code moves. In --json the
distinction is two fields: compatible is what was found, passes is what you asked about.
Every changed slot is listed, because the verdict cannot judge meaning
The report also carries the slots that changed at all — changed in the JSON (--json), on
every process, whether or not anything broke. Each entry names the address and which checks
it affects, so an edit that compares equal is still visible as an edit.
config_schema is not among them: it is re-resolved from the environment on every tick, so
nothing persisted corresponds to it. A process reading a config key that stopped existing is
caught by validation, which type-checks expressions against it — a better answer than compat
could give.
note
This is a shape check, not a review. Dollars → cents is number before and after and
comes back compatible. So is a reused enum member, or an id that moved to a new namespace.
The check catches the accidental break — a required input appearing, an output whose type
changed. It does not certify a migration.
Five more things it does not see:
- Routing. New
switchconditions decide where an instance goes next. Shape-compatible and behaviourally different is a normal outcome. - Side effects already performed. The old URL was already called.
- Redaction. A property that stops being
secret: truebecomes visible over the API, for data stored long before the change. A schema changing nothing else compares equal to every verdict, so thechangedlist is the only place it shows. - A field that may be absent. Adding an optional field to a task’s
outputis compatible — not because the two shapes match, but because the gap is one an upgrade can close mechanically, in whichever direction the edit opened it. A required nullable field that is missing gets its null written in; a stored null the new schema will not hold is removed, where the field is optional and absence is a valid state. Making that same field required and non-nullable is a break: neither the null nor its absence is valid, so there is nothing to write and nothing to drop. only_once. Removing it from a task whose previous attempt was interrupted lets that task re-run, and the side effect happens twice. Read theonly_onceslot before moving instances a worker died holding.
Over HTTP
POST /definitions/compat
{"from": {"channel": "latest"},
"to": {"versions": {"order_pipeline": 3}},
"process": "order_pipeline"}
Each side is a selector — channel, explicit versions, or submitted definitions —
resolving to one version per process name, paired by name. A triple of
{process, from, to} cannot name a graph, which is why the request is shaped this way.
A versions entry may be a number or a channel name: {"versions": {"boss": 2, "kid": "stable"}}.
ignore is accepted here too, as an array: {"ignore": ["contract"]}.
The response is one row per process named on either side, whatever became of it —
{compatible, passes, processes} and nothing more. compatible is what was found and
passes is what the caller asked about; with nothing ignored the two agree.
Each row carries a status (compared, nothing_to_compare, new, unanalysable)
alongside its two verdicts (upgrade, contract), so a row that was never judged is not
mistaken for one that passed, and a version that failed its own inference is found where you
would look for it rather than in a second array. issues lists what broke, each entry naming
its member (upgrade or contract), address, path, and whether it is gating.