Architecture overview
This document is the map that ties the per-area normative documents together:
the module layout, how one run moves data from spawn to exit, how the
control-plane clients (inspect/cancel/kill/attest) reach a live runner, where
this repository's responsibility ends and the processkit crate's begins, and
how the test suite is layered. It does not restate the normative contracts
themselves — each links to its own document below — and it is not a substitute
for reading the source; treat it as the entry point for a new contributor,
sketched from the code as of this writing rather than from memory.
Module map
processkit-cli is a thin binary (src/main.rs) over an internal library
(src/lib.rs): every other src/*.rs file is a mod of the library, and the
binary only parses argv and dispatches into it (see "Target structure: library
and binary" below for why the split exists and what stays stable).
Responsibilities, in the order data flows through a run:
| Module | Responsibility |
|---|---|
src/lib.rs | The internal library crate root: declares every src/*.rs module and re-exports nothing publicly-supported. It carries the "not a stable public API" disclaimer and marks each module #[doc(hidden)], so the library is only a foundation for the crate's own tooling — never a semver-stable Rust surface. |
src/main.rs | The thin binary entry point. Parses Cli, dispatches into the library's subcommand module, and maps the result onto a process exit code: run owns its own exit path (it hard-exits with the child's code and never returns here); every other subcommand's Result<(), RunnerError> is mapped through RunnerError::code(), and a clap parse failure is mapped onto the runner's own USAGE code rather than clap's default. It also resolves the one global option, --error-format, into the stderr rendering every failure path shares (src/error_envelope.rs). |
src/cli/ | The CLI-flags half of the compatibility surface: the clap-derived Cli/Command types for run, inspect, cancel, kill, attest, wait, events, list, prune, probe, and doctor, plus the one global option every subcommand shares (--error-format, whose rendering lives in src/error_envelope.rs). Parsing and shape validation only — each subcommand's behavior lives in its own module. A directory of eleven files, split by subcommand family rather than by size so a new flag or subcommand lands in a focused file: mod.rs holds the top-level Cli/Command shapes and re-exports everything below it, so crate::cli::<Item> stays the single path to every CLI type and parser; run.rs holds RunArgs/CaptureOverflowPolicy; control.rs holds InspectArgs and the TargetArgs shared by cancel/kill; attest.rs holds AttestArgs — a file of its own rather than a third user of control.rs's shared target form, because attest deliberately takes only --run-id: no --all (it asks about one named run, never "am I inside any run") and no --pid or other way to name a process (the identity comes from the control connection itself, so the only question it can pose is "am I inside this run?"), which is the opposite of the target shape cancel/kill share; wait.rs, events.rs (whose implementing module is src/events_cmd/, the plain events name being taken by the emitter), list.rs, prune.rs, probe.rs, and doctor.rs hold one argument struct each; and parse.rs owns the hand-written value parsers they share (durations, byte sizes, process counts, CPU quotas, exit-code bands, KEY=VALUE entries, bare environment keys, run ids) — the functions the fuzz tier drives directly. Cross-argument rules that compare two flags' values rather than their presence, which clap cannot express declaratively, live in Cli::validate/RunArgs::validate and are checked by src/main.rs between parsing and dispatch, so they refuse as ordinary USAGE (100) parse errors. |
src/run/ | The run subcommand itself: spawns the child into a processkit::ProcessGroup this module owns, selects either default pipe-and-echo I/O or direct inherited stdio, temporarily hands a POSIX terminal to a separate child process group when required, races the child's exit against --timeout/--idle-timeout/a local stop signal (Ctrl-C, on Unix SIGTERM/SIGHUP, and on Windows Ctrl-Break/console close/logoff/system shutdown)/a control-plane command, and drives the shared teardown tiers — the graceful soft stop → grace → hard kill for timeout (whole-run or idle)/a signal cancel/cancel, and the immediate hard kill (no soft stop, no grace) for a control-plane kill. Exit-code fidelity — the child's exact code on a normal completion, a reserved-band code for every runner-imposed ending — is enforced here. A directory of four submodules under one entry point, split by responsibility rather than by size: mod.rs is the entry point (run::execute) plus the shared ending vocabulary (Ending/Termination/CancelSignal/TimeoutTrigger/SoftTerminate); launch.rs owns the run itself (run_async: container creation, spawn, I/O-mode selection, the terminal handoff, and the race); signals.rs owns the runner-imposed deadlines and the platform stop-signal listeners (wait_for_cancel_signal, effective_grace_for); teardown.rs owns the two teardown tiers, the JSONL emitters, and the ending-to-exit-code mapping; and detach.rs is the --detach wrapper (start_detached), which re-spawns this binary detached and returns once the run has provably started, without duplicating any of the above. |
src/events.rs | The versioned JSONL lifecycle-event schema and its emitter — this repository's normative, golden-tested public event contract (see docs/schema.md). Also owns argv redaction: the default SHA-256 argv_sha256 fingerprint and the HINT_RULES worker-shape classifier, exposed as one shared CommandFingerprint so the registry record publishes the identical pair (see docs/registry.md, "Which run is which") instead of deriving its own. |
src/capture.rs | --capture-dir bounded per-stream stdout/stderr capture to files, riding the same tee run already echoes through (no second output-reading path). Records, per stream, a full byte counter, a SHA-256 of the bytes written, and independent explicit truncated/write_error flags, surfaced in the output_captured event. |
src/hash.rs | The one hand-rolled incremental/one-shot SHA-256 (FIPS 180-4) both events (argv fingerprint) and capture (streamed transcript hashing) build on, so the project has a single digest primitive and rendering style. |
src/text.rs | Shared human-output primitives: terminal-safe normalization for untrusted text and the column-aligned table renderer used by both list and inspect. |
src/registry/mod.rs | The per-user run registry: one record per in-flight run in an owner-only-restricted directory, found by scanning and matching run_id (never a PID), carrying the run's redaction-safe command fingerprint/hint for discovery, with staleness detected via an OS advisory lock the live runner holds (see docs/registry.md). The first brick of the control plane. |
src/control/ | The live-run control plane: a stable facade plus platform transports, bounded rendering, and isolated tests for its line-oriented inspect/cancel/kill/attest protocol (see docs/control-plane.md). The platform transports additionally own the kernel-supplied peer identity attest rests on (unix peer credentials, GetNamedPipeClientProcessId), read at accept time because it belongs to the concrete transport type rather than to the shared protocol. |
src/list.rs | The list subcommand: a thin, read-only CLI wrapper over Registry::entries that renders every registry entry — whatever its health (live/stale/unprobed) — as a table or JSON Lines, for a caller that has lost (or never had) a run_id. |
src/prune.rs | The prune subcommand: an equally thin wrapper over Registry::prune, which owns the whole confirm-before-delete reaping safety rule; this module only opens the registry and reports the tally. |
src/wait.rs | The wait subcommand: blocks until a run is no longer live, for a supervisor that is not the runner's parent. Registry-only in both of its modes — it never contacts the runner. --run-id polls Registry::probe_run and has three outcomes (finished, its own --timeout elapsing, an ambiguous run_id); --all (T-216) instead polls a Registry::entries snapshot taken at the moment it starts and has only two — there is no aggregate ambiguity outcome, since --all never resolves an id at all. Both are decided entirely from the registry (see docs/registry.md, "Waiting — wait"). |
src/events_cmd/ | The events subcommand: the read-back counterpart to src/events.rs's emitter. Resolves a stream by --run-id through Registry::entries (or takes --file), hands out only complete lines as the file grows, and renders them (render.rs, through src/text.rs's terminal barrier), passes them through verbatim (--json), or checks them against the embedded schema document (--validate: validate.rs runs the pass and owns the exit verdict, schema.rs interprets the document over the keyword subset it uses — refusing to run on anything it does not implement rather than skipping it — and pattern.rs is the tiny anchored matcher its pattern keywords need; tests/events.rs holds that checker's verdict against the jsonschema dev-dependency's, so the binary links no JSON Schema engine of its own). Read-only in the same sense list/wait are: no control transport, no mutation. |
src/probe.rs | The side-effect-free probe subcommand: reports (and, with --require-*, verifies) this binary's version/schema_version/exit-code band/CLI surface as one JSON line. |
src/doctor.rs | The side-effecting doctor subcommand: the runtime qualification of the host, where probe qualifies the binary. It performs a bounded scratch run of this very executable against its own harmless --scratch-child mode, then drives that run as an ordinary control-plane client (inspect, cancel, terminal wait) and reports what it observed — registry creation and owner-only protection, containment mechanism and abrupt-cleanup level, the transport round-trip, a confirmed-empty teardown, optionally the resource controller, and per-phase timings. Reimplements none of it: every phase drives the same production code a caller's own run and control clients take. Requirement flags gate only the exit code (exit::HOST_UNQUALIFIED, 116); the report carries the observed facts either way. Cleans up on success, keeps a named diagnostics directory on failure. |
src/exit.rs | The reserved runner-own exit-code band (100–119) constants — the exit-code half of the compatibility surface (see docs/exit-codes.md). |
src/error_envelope.rs | The machine-readable rendering of those same codes: the versioned, bounded JSON object the global --error-format json prints on stderr for any post-parse failure, and the ErrorKind taxonomy behind it — a finer axis over src/exit.rs's codes (it splits CONTROL eight ways and SETUP two), never a competing set of them. Not a compatibility surface of its own: it rides on the flag that turns it on and on the code it carries, and pins its own shape in the payload with error_version, exactly as the other machine-readable outputs do (see docs/compatibility.md, "Machine-output schemas"). Shared by src/main.rs's report_result and run::execute, so the two cannot drift; the run-family kinds reuse runner_exit.source's own spellings rather than forking that vocabulary (see docs/exit-codes.md, "Machine-readable failures"). |
Target structure: library and binary
The crate builds two targets from one source tree:
- an internal library (
src/lib.rs, Cargo targetprocesskit_cli) that owns every module undersrc/, and - a thin binary (
src/main.rs, Cargo targetprocesskit-cli) that does nothing but parse argv with clap and dispatch each subcommand into the library.
The library exists as a foundation for the crate's own tooling, not as a
reusable dependency. Keeping the runner's internals in a [lib] target lets:
- unit and property tests live in each module's
#[cfg(test)] mod testsand run as the library's--libtier under a plaincargo test, reaching module-private helpers directly — retiring thecargo test --binworkaround the old bin-only layout required; - fuzz targets (
cargo-fuzz,fuzz/, T-186) link the library and drive parsers of untrusted/semi-trusted input — the registry's bytes → parse/ validate path, the control plane's request-line classifier and response-line decode, the CLI's--timeout/--grace/--require-exit-code-band/--env/--run-idvalue parsers, andwait --report-outcome's terminal-outcome read-back over a run's JSONL events file (src/wait.rs, bounded head/tail scan for a terminalrunner_exit, T-301); theeventsreader's own parsers (src/events_cmd/) are not in this tier — a boundary thatCONTRIBUTING.md's "Fuzzing" section anddocs/threat-model.mdboth state outright, rather than leaving it to be inferred from this list. A[lib]target is a hard prerequisitecargo-fuzzcannot work without (seeCONTRIBUTING.md, "Fuzzing"); - benchmarks (criterion,
benches/, T-187) reach internal primitives — the hand-rolled SHA-256 hasher (src/hash.rs), bounded-captureabsorb(src/capture.rs), and the argv-redaction hint classifier (src/events.rs) — directly, to measure them in isolation (seeREADME.md, "Benchmarks").
Because the crate is published to crates.io, the library surface is deliberately
not a stable public Rust API: every module is #[doc(hidden)], the crate-root
rustdoc says so in as many words, and no item carries a semver guarantee. The only
supported compatibility surface stays the binary's contract — the CLI flags
(src/cli/), the reserved exit-code band (src/exit.rs,
docs/exit-codes.md), and the JSONL schema_version
(src/events.rs, docs/schema.md) — exactly as before this split. The
--error-format json envelope (src/error_envelope.rs) adds no fourth item to that
list: it is a machine-readable rendering built over the first two, versioned in its
own payload by error_version like the other machine-output schemas (see
docs/compatibility.md, "Machine-output schemas").
This document records only the target structure; the fuzz and benchmark tiers
themselves (T-186, T-187) are documented in CONTRIBUTING.md ("Fuzzing") and
README.md ("Benchmarks") respectively.
Data flow of one run
A processkit-cli run moves through the same sequence on every platform,
implemented in run::execute (src/run/mod.rs) and run::launch::run_async
(src/run/launch.rs):
- (
--detachonly) Hand the run to a detached copy.run::detach::start_detachedre-spawns this binary on the caller's own argv with--detachremoved (plus--run-id/--no-echowhere the caller left them out) — in a new session on Unix (setsid), as aDETACHED_PROCESSon Windows, withnullstdio either way — then waits until that copy'srun_startedline is readable in--jsonland exits, reporting only whether the run started (seedocs/exit-codes.md, "Detached runs"). The detached copy enters step 1 below and runs every subsequent step unchanged: detaching is a wrapper around this sequence, never a second implementation of it. - Spawn. The child is built from
processkit::Commandand spawned into aProcessGroupthis process owns — not a shared or global one — so the group's kernel-backed kill-on-drop (Windows Job Object, Linux cgroup/POSIX-group, FreeBSD process reaper, macOS process group) reaps the whole tree on every exit path this process lives to observe (normal completion, timeout, a local stop signal —Ctrl-C, on UnixSIGTERM/SIGHUP, or on WindowsCtrl-Break/console close/logoff/system shutdown — and a control-planecancel/kill). That guarantee does not extend to this process's own abrupt death (crash/SIGKILL/TerminateProcess), which skipsDropentirely: reaping a leaked grandchild after the runner itself dies abruptly is a platform-derived tri-state —whole_treeon Windows (Job Object survives the owner's abrupt death),direct_child_onlyon Linux (kill_on_parent_death/PR_SET_PDEATHSIG, direct child only),noneon FreeBSD, macOS, and other BSDs — surfaced per run asrun_started'sabrupt_cleanupfield (see K-005). Arun_startedevent (run id, root PID, containment mechanism, abrupt-cleanup tri-state, working directory) opens the JSONL stream. - Select the I/O path. By default,
processkit's line pump concurrently reads the child's stdout/stderr and drives two things off the same read: the live echo to this process's own stdout/stderr (src/run/launch.rs), and — when--capture-diris set — the per-stream tee insrc/capture.rs.--no-echoswaps only the echo sink for a discarding one (tokio::io::sink()in place oftokio::io::stdout()/stderr()); the pump, the--capture-dirtee, and the--idle-timeoutclock's re-arming on every observed chunk are all unaffected — seesrc/run/launch.rs's sink-selection block and K-050.--detachreuses that same swap rather than adding a second suppression path: the detached copy is started with--no-echo(step 0).--inherit-stdioinstead gives the child the runner's three handles directly; it conflicts with capture (and with--no-echo, which has no pump to act on in this mode, and with--detach, which has no terminal to hand over), so there is no pump or tee. When ProcessKit selects POSIX process-group containment and stdin is a terminal,runtemporarily assigns the terminal's foreground group to the child and restores the original group during cleanup. Amembers_snapshotevent records the container's member list — enriched withppid/executablename/start_timevia ProcessKit'smembers_info()wherever the platform can report them (docs/schema.md, "Enriched member fields") — in either path. With--snapshot-intervalthe same event is re-emitted on that cadence (reason: "interval") until the ending is decided, from an extra never-resolving arm of the run's existingselect!race rather than a thread or a spawned task, and reading the container's member list rather than the pump — which is why it composes with--inherit-stdiotoo. - Capture and hash.
src/capture.rs'sCaptureTeemirrors every byte the pump observes into a bounded capture file per stream — independent of whether that byte is also echoed (--no-echodoes not change what is captured) — hashing what actually reached disk withsrc/hash.rs's incremental SHA-256 and recording an explicit ceiling-truncation flag and write-error flag — never inferred from the file's size. This stage is a no-op, with no capture files and nooutput_capturedevent, unless--capture-dirwas passed; clap rejects--capture-dirtogether with--inherit-stdiobefore the child starts. - Teardown. Runner-imposed endings split into two tiers, not one shared
path.
--timeoutelapsing, a local stop signal (interactiveCtrl-C, on Unix a caughtSIGTERM/SIGHUP, or on Windows a caughtCtrl-Break/ console close/logoff/system shutdown), and a control-planecancelreaching the live runner oversrc/control/mod.rsall drive the same graceful path: a soft stop (SIGTERMto the tree on Unix; on Windows, which has no POSIX signal, a best-effortWM_CLOSEto any windowed member — nothing at all for the ordinary console child, in which case the grace window still elapses honestly with no soft stop delivered), a--gracewait, then the owningProcessGroup's kernel-backed hard kill-on-drop. A control-planekillis not part of that tier: it skips the soft stop and the grace window entirely and hard-kills the whole tree immediately via the same kill-on-drop mechanism — documented as immediate on purpose, not a shorter grace. A normal completion instead reaps via the same drop once the child's own exit is observed (root_exited, thencleanup_started/cleanup_finished). Every ending, forced or natural, first reads whatever the mechanism accounts for into aresource_summaryevent — and, where a cap was requested, thelimit_evidencethat precedes it — because both facts live in the container the teardown is about to destroy. The read point is therefore load-bearing for the numbers, not only for their availability: a forced ending reads a tree that is still running, a natural exit one that has already gone (see resource limits, consequence 5). runner_exit. The terminal JSONL event closes the stream, always carrying the outcome — the child's own exit code on a normal completion, or the reserved code for whichever runner-imposed ending fired (timeout/cancelled/killed, or a control-planecancelled/killed). The process's own exit code (seedocs/exit-codes.md) mirrors that same outcome, so a shell that never reads the JSONL stream still gets a faithful, distinguishable signal.
Control-plane contour
inspect, cancel, kill, and attest (src/control/mod.rs) never address a live run
by PID; they resolve it through the run registry (src/registry/mod.rs):
- Registry scan.
registry::Registry::entrieslists every record in the per-user registry directory and classifies each by probing the record's advisory liveness lock — a dead runner's leftover record is detected this way, not by mere file existence — as [registry::Health::Live], confirmed-dead [registry::Health::Stale], or (when the probe itself could not run, e.g. permission denied) [registry::Health::Unprobed]; this control-plane contour only ever acts onLive, soStaleandUnprobedare equally unactionable here — but not equally reportable: the refusal names a stale entry as a gone runner and an unprobeable one asunprobed(liveness unknown), the same distinctionlist/prune/waitdraw (seedocs/registry.md). - Endpoint resolution.
control::resolve_live_endpointmatches the requestedrun_idagainst the live entries only. More than one live match is an ambiguous run id — a hardCONTROL(103) failure for every verb, never a guess at which entry the scan happened to return first. The mutating verbs (cancel/kill) additionally re-run this resolution immediately before writing the verb (mutate_async), narrowing — though not fully closing — the TOCTOU window against a duplicate registering mid-flight (seedocs/control-plane.md, "Ambiguous run id"). - Verb over transport. The client connects to the resolved endpoint — a
unix domain socket or a Windows named pipe, both owner-restricted — and
speaks the shared line-oriented wire protocol: one request-verb line out,
one JSON reply line in.
inspectis read-only and prints aSnapshot;attestis read-only too and prints anAttestation— the runner's verdict on whether the process that opened this connection is inside its container, taken from the transport's own kernel-reported peer identity and answered on its ownattestation_versionaxis;cancel/killare mutating and reuserun's own teardown tiers exactly as described above —cancelthe graceful soft-stop → grace → hard-kill tier,killthe immediate hard-kill tier — replying with aControlAckbefore the run ends.
See docs/registry.md for the registry's location, record
format, and staleness signal, and docs/control-plane.md
for the full wire protocol and all three unreachable-runner cases (stale entry
vs. unprobeable entry vs. died mid-conversation).
Boundary with processkit
processkit-cli is a thin, standalone wrapper: the processkit
crate is the single source of truth for
containment, teardown, PID-reuse discipline, and process-tree lifecycle
semantics, and this repository builds strictly on its public API rather than
reimplementing any of that — a genuine gap becomes an additive request in
ProcessKit-rs's own backlog, never a local fork of the semantics (this is a
settled, repository-wide decision the module docstrings cite verbatim).
Concretely:
- What
processkitowns: the kernel-backed container (ProcessGroup) and its kill-on-drop teardown, the async child-output line pump (with a byte-cappedOutputBufferPolicy), directStdioMode::Inherit, and the environment builder (Command::env/env_remove/env_clear)run's--env*flags map onto directly. - What this runner owns: the CLI surface (
src/cli/), the versioned JSONL event contract and argv redaction (src/events.rs), the reserved runner-own exit-code band and its machine-readable failure envelope (src/exit.rs,src/error_envelope.rs), bounded diagnostic capture with hashing (src/capture.rs,src/hash.rs), the per-user run registry (src/registry/mod.rs), and the live-run control plane (src/control/mod.rs)/preflight probe (src/probe.rs) built on top of it, plus the temporary POSIX foreground-terminal handoff required by ProcessKit's separate process-group mechanism — none of whichprocesskititself provides.
README.md's introduction states the same division for a project outsider:
processkit-cli "runs one program inside ProcessKit's kernel-backed
containment boundary and reports the run lifecycle," while "ProcessKit-rs
remains the sole owner of containment, teardown, PID-reuse discipline, and
lifecycle semantics." Out of scope entirely: IPC-to-child protocols beyond the
control plane above, scheduling/pooling/retries beyond what processkit::Command
offers, a shell mode, and PTY support (deferred in the core crate).
Test tiers
Four tiers, increasing in weight and decreasing in how often they run:
- Unit. Each module under
src/carries its own#[cfg(test)] mod tests(for example the SHA-256 vector tests insrc/hash.rs, or theProcessGroup/Emitter-driven helper tests insrc/run/teardown.rs). The modules now live in the library (src/lib.rs), so these run as its--libtier under a plaincargo test— nocargo test --bin processkit-cliworkaround, which the earlier bin-only layout needed to reach module-private helpers. Internal helpers are tested against realprocesskit/Emitterobjects, never a mock layer. - Integration.
tests/drives the built binary (env!("CARGO_BIN_EXE_processkit-cli")), not the library, because the value this crate adds over ProcessKit-rs's own suite is the binary plus its contracts:tests/run.rs,tests/events.rs,tests/registry.rs,tests/probe.rs,tests/doctor.rs, andtests/integration.rscover through-the-binary scenarios, sharing fixtures/helpers fromtests/common/mod.rs. This is the defaultcargo testtier.tests/doctor.rsis the one case in it that is side-effecting by design — the command under test contains a real process and round-trips a real control plane — so it isolates both the registry (PROCESSKIT_CLI_REGISTRY_DIR) and the temp directory the command works in (TMPDIR/TMP/TEMP), which is what lets its cleanup claims be checked from outside the report that makes them. - End-to-end (
e2e, feature-gated).tests/e2e.rsis heavier still: it spawns real multi-level process trees, observes liveness from outside the runner (an OS process-table probe, not the container's own member list), and stresses concurrent runs, nested Windows Job Objects, PID-reuse storms, abrupt runner death, and inherited stdio through a real Windows console or POSIX pseudo-terminal. It is gated behind thee2eCargo feature (with itssrc/bin/e2e_helper.rsworker binary) so it stays off in the defaultcargo testand runs explicitly viacargo test --features e2e --test e2e -- --nocapture; CI runs it as a separate job. SeeCONTRIBUTING.md, "End-to-end tests". - Concurrency stress (
stress, feature-gated).tests/stress.rstargets what the tiers above cannot reach by construction: the invariants that only break when many runs contend for the two resources every run shares — the per-user registry (src/registry/mod.rs) and the per-run control plane (src/control/). Where thee2etier scripts a fixed handful of processes, this one launches dozens of simultaneousruninvocations against one registry directory and drives parallellist/prune/wait/inspect/cancel/killclients at them, asserting thatprunenever reaps a live entry (including one still inside its reservation window), that a registry scan never loses or duplicates a record under concurrent writes and deletions, that a control client aimed at an unreachable or dying runner refuses withCONTROL(103) inside a bounded deadline rather than hanging, and thatwaitnever misses — or invents — a completion. Every scenario carries a positive control, so none of those "never" assertions can pass vacuously. Gated behind thestressCargo feature and run viacargo test --features stress --test stress -- --nocapture; CI runs it as a separate, non-gating scheduled workflow (.github/workflows/stress.yml), not on every PR. SeeCONTRIBUTING.md, "Stress tests".
The property-based (proptest), fuzz (cargo-fuzz), mutation (cargo-mutants),
and benchmark (criterion) tiers cut across this ladder rather than sitting on a
rung of it — each re-examines code the tiers above already cover, from a
different angle. They are documented in CONTRIBUTING.md ("Fuzzing", "Mutation
testing") and README.md ("Benchmarks").
One further tier sits outside the ladder altogether, because its subject is not
this runner's behaviour but its agreement with the library underneath it.
Several of the vocabularies this CLI publishes (mechanism, abrupt_cleanup,
the limit_evidence verdicts, soft_stop_scope, soft_signal, outcome) are
projections of closed processkit enums, each ending in a conservative fallback
for a value the build predates — a safe answer that is also a silent one. The
upstream identifier drift gate (tests/spec_drift.rs, gated behind the
spec-drift Cargo feature) holds every one of those projections, and every
published schema enum that carries them, against the stable-identifier
dictionary processkit ships inside its own package, for the exact version
Cargo.lock resolves. A new upstream value fails the build instead of reaching a
wire as a fallback string. CI runs it as a separate gating job, and the scheduled
upstream canary runs the same tier against processkit's main branch. See
CONTRIBUTING.md, "Upstream identifier drift".
Normative documents
Each area of the compatibility surface has its own normative document; this overview only sketches how they connect:
docs/registry.md— the per-user run registry: location, record format, staleness signal.docs/control-plane.md— the local transport, the wire protocol, and theinspect/cancel/kill/attestclients.docs/exit-codes.md— the reserved runner-own exit-code band and the child-fidelity rule.docs/schema.md— the versioned JSONL lifecycle-event schema.docs/threat-model.md— untrusted inputs, the trusted principal and boundary, and which security threats the mechanisms sketched above (owner-only registry/transport, argv redaction, bounded control-plane reads, fail-closedprobe, bounded output capture, supply-chain scanning) actually close.docs/ROADMAP.md— the delivery status and the remaining ProcessKit-rs dependencies (this document describes the implementation).