ProcessKit CLI has three public compatibility surfaces:
- command names and flags;
- the reserved runner exit-code band;
- JSONL
schema_version.
Three, and only three — a versioned payload is not a fourth surface. Several
machine-readable outputs carry a version field of their own: probe --json's
probe_version, inspect's snapshot_version, the --error-format json
failure envelope's error_version, and attest --json's attestation_version.
Each of those rides on the list above — on the
command and flag that produce it (surface 1), and, for the failure envelope and the
attestation, on the
reserved code it reports (surface 2) — and pins its own shape inside the payload
rather than adding a further thing to pin before launching. (Count them separately:
"three compatibility surfaces" and "how many version fields this project publishes"
are different questions with different answers.) The registry record's
registry_version is not on the list either, for a different reason: the per-user
registry is a private contract between this binary and itself, not something a caller
reads off an invocation (see docs/registry.md). Which outputs carry a
version field, and why the rest deliberately do not, is "Machine-output schemas"
below.
The human CLI surface is guarded by through-binary golden snapshots for the root
help and every public subcommand in fixtures/cli-help/. An intentional flag,
value-name, default, or help change must be regenerated with
UPDATE_CLI_HELP_GOLDEN=1 cargo test --test cli_help and the fixture diff reviewed.
The test normalizes only the Windows .exe suffix and line endings; all contract
text and ordering remain exact.
Breaking any of those three surfaces requires a major release. An adapter should verify the exact pieces it uses before launching a payload rather than discovering an incompatible binary after work has started.
processkit-cli probe --json \
--require-schema-version 1 \
--require-exit-code-band 100-119 \
--require-surface run \
--require-surface run:--jsonl \
--require-surface run:--capture-dir \
--require-surface inspect:--json \
--require-surface cancelprobe is side-effect-free. It launches no child, creates no container, and
does not touch the registry. A missing requirement exits
PROBE_INCOMPATIBLE (110) and reports all mismatches.
That answers a question about the binary. The matching question about the
host — can this machine actually create the registry, contain a process, and
round-trip the control plane? — is what doctor answers, by doing all three in a
bounded scratch run and reporting what it observed; an unmet host requirement exits
HOST_UNQUALIFIED (116). It is a setup-time check rather than a per-launch one,
because unlike probe it has real (self-cleaning) side effects. See
docs/integration.md §1 and
docs/troubleshooting.md, "Qualifying a host: doctor".
A surface token takes one of three forms — a subcommand, subcommand:--long-flag,
or subcommand:capability:
run
run:--jsonl
run:--idle-timeout
inspect
inspect:--json
cancel:--all
attest:peer-identity
run:resource-summary
The first two forms name a spelling the parser accepts, and are derived from the
live clap definition so they cannot drift from the real one. The third names a
capability instead — "can this binary do the thing", which no parser can
answer — and is told apart by carrying no --. The missing -- is what keeps the
two categories from being read as one
another, so an adapter that validates this array with a pattern of its own must
admit the ---less form; the published grammar is the surface pattern in
fixtures/schema/cli/probe.schema.json.
There are two capability tokens, and they differ in why they might be absent:
attest:peer-identity— a platform capability. Published exactly where this build can obtain a kernel-authenticated identity for a control-plane client, which is what makesattestable to return a membership verdict at all (seedocs/control-plane.mdanddocs/integration.md, §1). A given target may lack it.run:resource-summary— a build capability. Published by every build whoserunemits the terminalresource_summaryevent (seedocs/schema.md). No platform removes it; only an older binary lacks it. That is exactly why it needs a token: an event's presence is otherwise undiscoverable until a run has already finished without it, which is after the work the number was wanted for.
A consumer requires either with the same --require-surface and does not need to know
which kind it is holding.
A capability token's presence is a guarantee, and it is worth being precise about
what is guaranteed in each case. attest:peer-identity guarantees this target names
the peer, so a negative membership answer from it is a real verdict rather than a
missing capability in disguise. run:resource-summary guarantees the event will be in
the stream — not that any particular measurement in it is populated: which axes
carry numbers is a property of the containment mechanism (run_started.mechanism) and,
for memory and CPU on Linux cgroup v2, of how the run ended. The normative matrix is in
docs/resource-limits.md. A single token
could not honestly carry five per-platform facts, and splitting it into five would
publish as capabilities what are really documented properties of the mechanism — which
is also why no token could have promised numbers a read point decides.
A capability's absence withholds its guarantee rather than predicting failure —
attest on a build without the token still answers from whatever the kernel actually
provides, and fails closed with peer_identity_unsupported when that is nothing.
Requiring it therefore turns "this platform cannot prove membership" into an ordinary
PROBE_INCOMPATIBLE (110) at preflight, instead of a refusal in the middle of a job.
Require only the features the adapter will actually use. This permits additive
CLI releases while preventing an invocation from reaching a binary that lacks
a needed flag. That is why the preflight example above pins no capability token: it
shows an adapter that uses run, inspect, and cancel and nothing else. An
adapter that will gate work on containment membership adds
--require-surface attest:peer-identity to its own invocation — an adapter that will
not must leave it out, since requiring an unused capability would fail preflight on a
platform whose missing capability could not have affected it.
Every event carries schema_version. An adapter should reject an unknown value
before interpreting event-specific fields. The current schema is documented in
JSONL event schema and available from the installed binary:
processkit-cli probe --json --print-schema > schema.json--print-schema is mutually exclusive with --require-*: printing the
document and checking compatibility are separate operations, so neither can be
silently skipped.
Within one schema version, a reader must tolerate every one of the following. Removing a field, renaming it, or changing the meaning or type of an existing one is what requires a new schema version — nothing below does.
-
New event types — including ones that appear in every run. Route by the
eventdiscriminator and ignore a type you do not know, rather than failing on it or assuming the stream is corrupt. Note the scope of this obligation carefully: a new type need not be gated behind a flag.resource_summary(seedocs/schema.md) is the worked example — it is emitted by every run that spawned a child, so a defaultrun's stream is one line longer than an earlier v1 build's. Nothing existing changed, which is what makes it additive; a reader that pinned the exact set of event types a run emits, rather than routing by tag, is the only kind that notices. -
More occurrences of an event type you already know, including a type that previously occurred at most once. Within a version you may not assume any event type is unique, or that its position in the stream is fixed relative to other types beyond what the ordering contract states.
members_snapshotis the worked example: it appeared exactly once per run untilrun --snapshot-intervalmade a run emit it on a cadence. -
New fields on an event you already parse — including always-present ones, not only optional ones. "Additive" here means "no existing field changes", not "the new field may be absent":
members_snapshotgained an always-presentreasonand an always-presentread_error,timeoutgained an always-presentreason, andcleanup_started/cleanup_finishedgained always-presentread_errorflags, all within version 1. A reader must therefore consume the fields it uses rather than pin an event's exact field set — validating withadditionalProperties: falseagainst a copy of a published document will fail on the next additive release (see "Machine-output schemas" below). The newlimit_evidenceevent is likewise additive: readers should route byevent, ignore the new type when they do not use resource-limit attribution, and keepschema_versionpinned at1.A requested cap that fails during
ProcessGroupcreation remains the pre-spawnlimit_hitpath. Since no group exists on that path, it has nolimit_evidenceevent; readers must not synthesize anunknownevidence record for it.The
resource_summaryevent carries the same obligation in a stronger form, because every one of its measurements is nullable: anullmeans "this mechanism, at this read point, does not account for it" and must not be read as, or replaced by,0. Check itsread_errorflag before drawing any conclusion from anull— an all-nullsummary is a correct reading on a mechanism with no whole-tree accounting, and equally on a flagless Linux cgroup v2 run whose child exited on its own; only that flag distinguishes either from a read that failed. The per-axis platform matrix is indocs/resource-limits.md— read it before relying on an axis, because two of them are governed by the read point and not by the platform alone — and its two IO counters are explicitly not comparable across platforms. -
New values in an open-ended descriptive string field — a new
cancelledsource, a newrunner_exitsource, a newhintlabel. Treat an unrecognized value as "some other trigger" and keep routing by event type. -
New values in the growing
mechanismvocabulary.process_reaperis an additive value within schema v1: no existing mechanism changed meaning, and readers must treat an unfamiliar mechanism as an unsupported containment choice rather than reject the complete event or machine-output record.unknownis an intentional contract value emitted by the CLI's conservative fallback for a futureprocesskit::Mechanismvariant, not a projection error. The published schemas enumerate the current vocabulary, so an older frozen schema may reject a newer value; strict validation must use the producer-matching schema, and an adapter's runtime parser must not treat the old enum as exhaustive. -
An escaped element in
--argv-raw'sargvarray. The field's type is unchanged (array of string) and every element that is valid Unicode is still the argument verbatim, but an argument the platform accepts and Unicode cannot express — ill-formed bytes on Unix, an unpaired surrogate on Windows — is now recorded losslessly in an escaped form opened by U+0000, instead of the U+FFFD-mangled reconstruction earlier v1 builds wrote. A reader that displays or logsargvneeds no change, and neither does one that stores or relays the JSONL line: U+0000 goes on the wire as the ordinary six-character JSON escape, so the line stays NUL-free text. The correction adds two reader obligations, both confined to that one element shape:- A reader that reconstructs an argv (to re-run, compare, or match against one
it holds) must decode that form — the grammar is in
docs/schema.md, "Raw argv that is not valid Unicode" — and must not take an escaped element literally. - A reader that stores or forwards a decoded element must check that its sink
accepts U+0000; this is the only place in the schema where a string value can
carry that code point, so a sink that rejects it will have accepted every
stream earlier builds produced. PostgreSQL refuses a
jsonbdocument containing it (an adapter that ingests whole event lines intojsonbloses the entirerun_startedevent, not just the field), a C-string API truncates the element at its leading NUL and is left with nothing, and some YAML and log transports refuse a NUL in a scalar. Such a reader decodes the escape and re-renders it for its sink — or escapes or drops the marker itself — instead of passing the decoded string through.
argv_sha256adds no obligation at all, since every digest an earlier build produced for an ordinary command line is unchanged. - A reader that reconstructs an argv (to re-run, compare, or match against one
it holds) must decode that form — the grammar is in
-
Unknown fields anywhere in the envelope or event body.
The normative source is docs/schema.md, not this page: its
"Ordering" section (and specifically "Multiplicity of members_snapshot") states
how many of each event a stream may carry and where, and its "Versioning" section
defines exactly which changes are additive and which are breaking. This section
restates those obligations for an adapter author; where the two ever disagree,
docs/schema.md wins and the disagreement is a bug in this page.
Several of the enumerations above are not this project's own inventions: the
mechanism, abrupt_cleanup, limit_evidence verdict, soft_stop_scope,
soft_signal, and outcome values are projections of closed enums in the
processkit library this runner is built on. Those enums grow — process_reaper
above is one such growth — and every projection here ends in a deliberate
fallback (unknown, none, failed) so that a build which meets a value it
predates degrades honestly instead of guessing.
A fallback is a safe answer, not a good one, so the project does not rely on
noticing upstream growth by hand. processkit publishes a machine-readable
dictionary of its stable identifiers inside its own package, and a gating test
tier (tests/spec_drift.rs; see CONTRIBUTING.md, "Upstream identifier drift")
holds every projection and every published schema enum against the dictionary
belonging to the exact library version this binary is built with. A value that
would land in a fallback arm fails the build, naming the enum and the identifier.
What that means for an adapter is narrow and worth stating exactly. It is a
promise about this project's process — that a borrowed vocabulary cannot grow
without someone deciding what the new value means here — and not a promise that
your pinned schema copy stays complete: a decision may still add a value in a
later release. Points 4 and 5 above are unchanged obligations. It also covers
only the vocabularies borrowed from processkit; the ones this CLI mints itself
(a cancelled source, a runner_exit source, a hint label) have no
upstream dictionary to be checked against and remain open-ended by design.
The JSONL stream is not the only machine-readable output. The discovery and
control commands print machine-readable JSON to stdout too, and each of those
shapes has a published JSON Schema (draft 2020-12, self-contained) plus a golden
fixture under
fixtures/schema/cli/:
| Output | Schema document | Golden fixture |
|---|---|---|
probe --json |
fixtures/schema/cli/probe.schema.json |
probe.jsonl |
list --json |
fixtures/schema/cli/list.schema.json |
list.jsonl |
inspect --json, inspect --all --json |
fixtures/schema/cli/inspect.schema.json |
inspect.jsonl |
cancel/kill ack, cancel --all/kill --all report |
fixtures/schema/cli/control-ack.schema.json |
control-ack.jsonl |
prune --json, prune --dry-run --json |
fixtures/schema/cli/prune.schema.json |
prune.jsonl |
wait --report-outcome, wait --all --report-outcome |
fixtures/schema/cli/wait.schema.json |
wait.jsonl |
the --error-format json failure envelope (stderr, any subcommand) |
fixtures/schema/cli/error.schema.json |
error.jsonl |
attest --json |
fixtures/schema/cli/attest.schema.json |
attest.jsonl |
doctor --json |
fixtures/schema/cli/doctor.schema.json |
doctor.jsonl |
The failure-envelope row is the odd one out in two ways, both deliberate. It is
printed on
stderr, not stdout — that is what keeps stdout reserved for successful output,
so a command that prints a report and then fails leaves its stdout untouched — and
it describes a failure rather than a success, which is exactly why the eight
success shapes beside it could never have covered it. It is opt-in: without
--error-format json, a failure prints the same free-text prose it always did.
Two of the table's rows are verdict shapes, and both are routinely printed
alongside a non-zero exit, with no flag involved in either. Two of
attest --json's three
verdicts make the command fail, and the attestation is printed for all three (see
docs/control-plane.md, "attest"); doctor --json exits
HOST_UNQUALIFIED (116) whenever a phase failed or a --require-* expectation about
the host went unmet, and prints the same report either way (see
docs/integration.md §1 and
docs/troubleshooting.md, "Qualifying a host: doctor"). In
both cases the verdict is the answer and the exit code only says what to do about it,
so neither channel replaces the other: do not treat a non-zero exit as "there is no
stdout to parse". (probe --json behaves the same way but only when the caller
asked for a --require-* expectation the binary did not meet, and inspect --all's
and cancel/kill --all's report arrays precede a CONTROL (103) that reports what
could not be done rather than what was decided — a different fact, the same
reading discipline. wait --report-outcome is neither: it prints only when the wait
succeeded.)
events --json is deliberately absent from that table: it passes the runner's own
JSONL lines through byte for byte, so the document that describes it is the event
schema above (fixtures/schema/v1/schema.json), not a second one of its own. That
is also what events --validate checks a stream against.
tests/machine_output.rs validates the real binary's output for each of these
against its document on every test run, so an accidental shape change fails CI
instead of reaching an adapter. A document whose family has more than one output
form has a root oneOf over named $defs, so a consumer can validate against the
exact form it invoked — for example inspect.schema.json#/$defs/snapshot.
Five rows of that table carry a version field; the other four deliberately do
not. probe --json carries probe_version, inspect --json carries
snapshot_version — the same field the runner puts on the control-plane wire —
the failure envelope carries error_version, attest --json carries
attestation_version, and doctor --json carries doctor_version.
probe.schema.json, error.schema.json, attest.schema.json, and
doctor.schema.json pin their value
with const;
inspect.schema.json admits the
range of snapshot versions this build renders (see the snapshot_version bullet
below), because that field reports the runner's contract, not the invoked
binary's. The shape follows the reader's tolerance rather than a sibling's
precedent: attest's value also comes off the wire, and is pinned anyway, because
its client refuses any version but its own outright — a membership verdict read
under unpromised semantics is worse than no verdict. Either way a bump is visible in
the payload itself. Pin those five on
their own version field, not on the CLI version alone: ignoring a
snapshot_version bump across an upgrade is exactly the class of mistake this
section exists to prevent. All five
are versioned for the reason the project's other two versioned contracts (the
durable JSONL stream's schema_version and the registry record's
registry_version) are: each can be read by a party that did not invoke the
binary — the snapshot and the attestation cross a process boundary to a runner that
may be a different build, the probe report's whole job is to be read before the
binary's version is known, a failure envelope is routinely read out of its
invoking context altogether (captured stderr in a CI log, read back later by a
different tool than the one that ran the binary), and a doctor report is meant to
be kept: a failed qualification writes it into the diagnostics directory it leaves
behind, precisely so it can be read later, elsewhere, by whoever is debugging the
host rather than by whoever ran it.
The remaining four — list --json, the cancel/kill ack and --all report,
prune --json, and wait --report-outcome in both its forms — carry no version
field, deliberately. Each is a synchronous stdout rendering read by the caller
that just invoked this exact binary. That includes the printed ack, whose content
does arrive over the wire but is re-serialized by the client from the three
fields it parsed and verified, so its field set is the client's own (see
fixtures/schema/cli/README.md,
"Versioning"). Such a caller already knows the version and can pin the shape
through the probe preflight above — the reported version, plus one
--require-surface token per subcommand and flag it will actually use. A
per-output version field would be a second, redundant pinning axis, so none was
added.
Consequently:
- The unversioned four ride on the first compatibility surface (command names and flags), not on a version integer of their own. A breaking change to any of them — removing a field, renaming it, changing its type or the meaning of a value — is a major release, announced in the changelog.
probe --json,inspect --json, the failure envelope,attest --json, anddoctor --jsonadditionally bump their own field. A breaking change to either of the first two shapes bumpsprobe_version/snapshot_versionrespectively, and that field is what a consumer should check. The envelope'serror_versionworks the same way: removing or re-typing one of its stable fields, or changing what an existingkindmeans, bumps it, while a new field or a newkindvalue is additive and does not (seedocs/exit-codes.md, "Machine-readable failures", and the "Stability" section there). For the snapshot, this binary's owninspectclient checks it too, and does so asymmetrically: a runner answering with asnapshot_versionnewer than this build implements is refused withCONTROL(103) rather than rendered under semantics its sender never promised, while an older one is still read for as long as this build genuinely decodes it (today: version 1, the version every release so far writes). Seedocs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read", for the rule, the floor, and what moves it. Two consequences for an upgrade: a bump is a hard boundary for older clients in a mixed deployment, not merely a signal to read; and thesnapshot_versionon stdout is the runner's number, soinspect.schema.jsonadmits the range this build renders instead of pinning a single value.attest --json'sattestation_versionis checked too and is the strict case of the same rule — any version but this build's own is refused, because a membership verdict must never be read under semantics its sender did not promise, and because that contract has had only one version so far, so strictness refuses nothing that ever existed. Whether a wire-supplied version pin is a range or a single value follows what its reader actually decodes, never what a sibling document does.- Every document under
fixtures/schema/cli/is updated in place. There is novN/directory there (unlikefixtures/schema/v1/, whosev1is the JSONLschema_version), because a version here, where there is one, lives in the payload rather than in a path. If a future release gives one of the unversioned four a version field of its own, that is the point to revisit the layout. - Additive changes remain minor/patch, exactly as they are for the JSONL stream, and a reader that consumes the fields it knows is unaffected.
The published documents set additionalProperties: false so that this repository's
own tests fail when a field is added without publishing it. An adapter that copies a
document into its own pipeline and wants to tolerate a future additive field should
relax that keyword on its copy rather than pin a field set the project treats as
additive.
Normal foreground runs forward the child code. Runner-owned outcomes occupy
100–119. Because a child may itself return a number inside that band, the
terminal runner_exit.child_code / runner reason remains the authoritative
machine distinction.
Verify the whole reserved band, not only the individual codes your current version knows. This protects space for additive runner outcomes without making an old adapter classify them as child exits.
Detached launch is the documented exception: launcher 0 means the run
started, and the child's eventual code is in terminal JSONL.
- Download and verify the new archive without replacing the active binary.
- Run the new binary by absolute path with the adapter's full
proberequirements. - Compare
probe --print-schemawith the adapter's pinned schema version. - Exercise one harmless run through the same I/O, capture, and control-plane flags used in production.
- Confirm
run_started.mechanismin the actual deployment environment. - Atomically replace the installed binary.
- Keep the previous binary until terminal events from the smoke run have been consumed successfully.
The project changelog uses Keep a Changelog categories. Look for:
Added: optional flags/events/features an adapter may adopt;Changed: behavior that remains inside the current compatibility contract;Fixed: corrections that may alter previously buggy observations;Removed: major-version migration work.
The manifest version, v<version> tag, crates.io artifact, and GitHub Release
are produced by one release workflow and should identify the same release.
The control plane lives inside each running process. Replacing the executable on disk does not upgrade a runner already in memory. A new client must therefore remain compatible with the live run's registry/control/schema contract until that run finishes.
For a conservative rolling upgrade:
- stop launching new work through the old binary;
- let or cancel old live runs;
- wait for the registry to become empty;
- preview and prune stale entries;
- replace the binary and launch new work.
Do not use recorded PIDs to bridge versions.
Run the target older binary's probe by absolute path before replacing the
newer one. An older binary may lack an additive flag even when the JSONL schema
version is unchanged. Surface-token verification catches that case.
Preserve event files across a downgrade; they are durable observations from the
binary that produced them and must be decoded according to their own
schema_version, not the currently installed executable.
The rolling-upgrade and downgrade procedures above are not only described here.
A scheduled, non-gating workflow (.github/workflows/interop.yml) downloads the
latest published release and runs both directions of them against the current
build every week — new clients over an old runner and the reverse, an abandoned
record reaped from either side, probe pinning both ways, and each binary's
JSONL stream read under the other's schema. See CONTRIBUTING.md,
"Cross-version interop".
- exact supported
schema_version; - exact reserved exit-code band;
- every command and long flag the adapter invokes;
- required containment
mechanism; - required
abrupt_cleanupstrength; - I/O mode/capture assumptions;
- resource-limit applicability in the real environment.
The last four are properties of the deployment, not of the binary, so probe
cannot answer them: confirm them on each host with doctor
(--require-mechanism, --require-abrupt-cleanup, --check-resource-controller --require-resource-controller), which reports what the machine actually did rather
than what its platform can do in principle.
- Integration guide — complete adapter lifecycle.
- Exit-code contract.
- JSONL event schema.
- Platform support.