014: Incident evidence bundles¶
Status: accepted, 2026-11-09.
The problem¶
When a job misbehaves, the evidence that would explain it is spread across a
checkpoint directory, a coordinator's HA directory, a capture tree and,
when tracing was on, a directory of protocol traces. Each has a tool -
checkpoint-verify, state-cat, capture-cat, replay, the formal trace
validator - but nothing gathers the pieces that belong to one incident, moves
them off the machine, and says, offline, which facts the pieces establish and
which they do not. An engineer who was not there has to reassemble the layout
by hand and decide alone what a passing tool run means.
This record adds one workflow: collect a bounded bundle, verify it offline, replay what can be replayed, and freeze a reproduced epoch as a regression test. It adds no new state format, protocol or replay engine: every check calls the production code that already decides the question.
The workflow¶
# 1. On the machine that ran the job: gather the evidence for job 1.
clink incident-collect --checkpoint-dir=/data/ckpt --job=1 \
--ha-dir=/data/ha --capture-dir=/data/capture --trace-dir=/data/traces \
--include-state --include-captures --out=incident-1
# 2. Anywhere else, with no cluster and no services: what does it establish?
clink incident-verify incident-1 --profile=replay --expected=oracle.ndjson
clink incident-verify incident-1 --profile=replay --json > report.json
# 3. Freeze a reproduced epoch as a permanent regression test.
clink incident-regress incident-1 --op=3 --epoch=4 --out=tests/incidents/inc-1
The commands follow the CLI's flat, hyphenated names (checkpoint-verify,
capture-cat, replay-diff).
The bundle¶
A directory, published by renaming a completed temporary directory into place:
| Path | Role | Present when |
|---|---|---|
manifest.json |
the index: format, provenance, selection, every artifact's path, size and SHA-256, every absence and truncation | always |
job/manifest.json |
the coordinator's persisted job manifest (graph, checkpoint config, confirmation flag, capture settings), credentials redacted and the graph before a rescale left out | --ha-dir names a directory holding it |
checkpoint/_jobs/<job>/ |
the job's COMPLETED-<id>, CONFIRMED-<id> and RESTORED records, byte for byte |
always |
checkpoint/_state/<job>/v<gen>/<subtask>/checkpoint-<id>.snap.meta |
each participant's integrity sidecar | always |
checkpoint/_state/<job>/v<gen>/<subtask>/checkpoint-<id>.snap |
each participant's snapshot, and each captured epoch's starting snapshot | --include-state |
capture/op-<id>/subtask-<s>/op.json, capture/op-<id>/subtask-<s>/epoch-<n>.cap |
the flight recorder's epochs and operator specs | --include-captures |
traces/<process>.ndjson |
one run directory of protocol traces | --trace-dir |
The checkpoint and capture subtrees keep the engine's own layout, so the
production functions that resolve a restore (resolve_restore_source,
state_dir_for) and a replay (EpochReplay) run on the bundle unchanged.
Paths in the manifest are relative; nothing in a bundle depends on where it
was written or on a running cluster.
The manifest is versioned ("format": "clink-incident-bundle",
"version": 1). A reader refuses a format it does not know and a version
above its own; additive fields are ignored by older readers.
The checks¶
Each check reports passed, failed, unavailable (the evidence it needs is
not in the bundle, or a tool it needs is not on this machine) or unsupported
(the check cannot be made for this kind of evidence in this release), with a
stable identifier, the evidence it used, the reason, and the next action.
passed means exactly what the check says and no more.
| Check | Passed means | Uses |
|---|---|---|
bundle.manifest |
the manifest parses, is a known format and version, and every path in it is relative and inside the bundle | the bundle reader |
bundle.artifacts |
every listed artifact exists with the listed size and SHA-256, and nothing unlisted sits in an evidence directory | SHA-256 |
bundle.completeness |
every artifact the requested profile needs is listed; absences the manifest records are reported, not excused | the manifest's absent list |
checkpoint.marker |
COMPLETED-<id> for the selected checkpoint parses and names this job, this checkpoint and a generation |
parse_completed_marker |
checkpoint.restore_rule |
the engine's restore choice, recomputed over the bundle's markers, is the selected checkpoint | restore_point_on_disk (the coordinator's own functions) |
checkpoint.participants |
every participant the marker records has its snapshot sidecar under the marker's state root and generation, and with --include-state the snapshot verifies against it |
resolve_restore_source, state_dir_for, verify_checkpoint |
state.decode |
every included snapshot restores through the production restore path; its schema stamps and fingerprints are listed | InMemoryStateBackend::restore |
protocol.trace_structure |
every trace file parses, and each process's sequence numbers start at 1 and have no gap | the trace files |
protocol.conformance |
the existing TLC trace validator accepts the run | scripts/formal-check.sh --trace |
replay.determinism |
two replays of an epoch produce identical emissions | EpochReplay |
replay.recorded_output |
the replay's emissions equal the records its single downstream operator captured for the same epoch | EpochReplay, the capture reader |
replay.expected_output |
the selected epochs of one operator, each replayed from its starting state, emit an independent expectation's rows, given with --expected, compared as multisets |
EpochReplay |
destination.reconciliation |
(reported unsupported in every full report and required by no profile: offline verification reads no destination; clink incident-reconcile reads one, explicitly, as record 015 sets out) |
- |
Profiles name the checks a run requires: integrity (the bundle.* checks),
consistency (adds checkpoint.* and state.decode), replay (adds
replay.determinism and replay.recorded_output, and with --expected
replay.expected_output) and full (adds the protocol checks).
Exit codes: 0 every check the profile requires passed; 1 a check failed;
3 none failed but at least one required check is unavailable or
unsupported (a partial result); 2 bad usage or an unreadable bundle.
protocol.conformance validates the bundle's job alone, and a run the
specification does not model is unsupported: the validator skips it and
validates nothing.
Two replays agreeing establishes determinism, not agreement with production;
only replay.recorded_output compares with what production did, and only
where production's output was captured. A truncated capture can establish
neither for the whole epoch: every replay check reports it unavailable, and
the determinism check's details say whether its stored prefix agreed with
itself.
Each replay check answers for the selection --op, --subtask and --epoch
name, every captured epoch when none is given, and reports how many epochs it
selected and what became of each. --max-replays bounds how many one run
replays; the rest are counted as omitted, never dropped from the selection.
A check fails when any selected epoch failed, whatever the others show, so a
divergence is never hidden behind missing evidence elsewhere. It passes only
when every selected epoch was checked whole and passed. Otherwise an omitted
epoch or one without its evidence makes it unavailable, and one it cannot
replay or compare makes it unsupported: a partial result either way, which a
smaller selection or a larger bound can complete.
An epoch replays from the checkpoint its events were processed from: the one
before it, except for a restored run's first epoch, which starts from the
checkpoint that run restored. The bundle knows the restores its RESTORED
record (the newest) and its traces' Redeploy events name. Before the newest
restore, a job whose sinks need commit confirmation could have restored from
any confirmed checkpoint, so there an epoch whose predecessor was never
confirmed is unavailable rather than replayed from a state that may be wrong;
the first epoch after a rescale is unsupported. The starting snapshot must be
in the bundle. An epoch a recorded restore passes over was processed by a run
the restore discarded, so replay.expected_output leaves it out of the union
it compares, and an epoch whose capture holds part of it makes that check
unavailable. replay.recorded_output places a capture in the job graph by its
uid or, for an operator without one, by its type and parameters less the Row
layout a deployment stamps, and compares each key's rows in order, since the
engine orders nothing across keys. An operator it cannot compare by its
topology is unsupported, and one it lacks evidence for is unavailable.
Trust¶
The SHA-256 digests establish that a bundle is internally intact: nothing in it changed after collection. They do not establish who made it: anyone able to replace the whole bundle can replace its manifest too. Signing is out of scope.
A bundle is untrusted input. The reader refuses absolute paths, ..
components and symbolic links; bounds the number of files, each file's size,
the manifest's size and JSON depth; refuses an unknown format or version;
and never runs a command or loads a plugin a manifest names. Replay rebuilds
operators from the binary's own registry and loads no plugin, so a plugin
operator reports unsupported (clink replay --plugin replays it). Operators
that call an external system for each record, lookups and model inference,
report unsupported without being built: verification contacts no service.
Collection¶
The collector selects the checkpoint with the engine's restore rule over the
markers on disk, the same functions a coordinator takeover calls: the newest
COMPLETED marker the job's RESTORED record does not pass over, or, for a
job whose sinks need commit confirmation, the newest CONFIRMED one.
--checkpoint overrides the choice, and the manifest records both. The
in-doubt walk a takeover runs for the confirmation family contacts external
systems and can advance the point; offline it cannot be predicted, and the
manifest says so.
Concurrent changes: the collector lists the job's markers before and after copying and refuses to publish when they differ; it hashes each file as it copies and re-reads its size and modification time afterwards, refusing on any change; it never mixes checkpoints or generations, because every state path derives from the one marker. It does not pin anything: clink has no retention lock, and adding one is a distributed service this record does not want. A checkpoint that disappears during collection fails the collection with the reason, and the user collects again or stops the job first.
A coordinator removes a job's manifest from its HA directory when the job
ends. Collecting while the job runs keeps the graph and the restore rule in
the bundle; after it has ended, --commit-confirmation supplies the rule and
the recorded-output check has no graph.
Bounds: --max-bytes (default 1 GiB), --max-files (default 10,000) and
--timeout-s (default 600) stop collection with an error; --op and
--epochs narrow the captures, and what selection leaves out is listed as
excluded in the manifest.
Sensitive data: graph parameters whose names suggest a credential
(password, passwd, secret, token, credential, api_key, apikey,
access_key, private_key, sasl, auth, conninfo) are replaced by
<redacted> in job/manifest.json, and the graph before a rescale is left
out. A bare key is not on the list: it names key columns far more often
than secrets. Snapshots and captures hold the job's
records and keyed state, which no redaction can make safe, so they are
collected only with --include-state and --include-captures, and the
manifest records whether they were.
Unsupported in this release¶
Coordination stores other than the local filesystem (an s3:// checkpoint
directory), RocksDB SST checkpoints for state.decode, state outside the
checkpoint directory, replay of multi-input operators, non-Row channels and
plugin operators, a live per-subtask view of a running job, signing.
Destination reconciliation is the separate, explicit command record 015 adds;
offline verification still reads no destination.
Implementation and tests¶
include/clink/cluster/in_doubt_resolution.hpp: the coordinator's marker-scan functions move here unchanged, withrestore_point_on_disk.include/clink/incident/bundle.hpp,src/incident/: the manifest, the reader's bounds, collection and the checks that need no SQL runtime.include/clink/sql/incident_replay.hpp,src/sql/incident_replay.cpp: the replay checks and regression bundles, onEpochReplay.tools/clink_incident.cpp: the three commands;clink replay --run-testruns a regression bundle as its generated test does.- Tests: every check's positive case and its mutations (a missing, modified or unlisted artifact; a truncated snapshot or capture; a wrong job, checkpoint or generation; mismatched participants; a restore rule that chooses another checkpoint; a missing starting state; an undecodable snapshot; a gap in a trace; altered captured events; changed recorded output; a manifest claiming evidence it lacks; unsafe paths; limits), collection races, atomic publication, and replay's determinism-without-agreement and truncated-capture cases.
- A runnable example,
examples/incident-verification/, with a worker killed at an asserted point and an independent oracle, which is also the integration gate. Its verifications run under the operating system's network isolation, proven first on that machine against a local control endpoint (scripts/network-isolation.sh). A run without it, which only an explicit development override allows, and a run whose conformance check was unavailable are never a qualification: the example exits 3 and the gate fails.