Provenance¶
Provenance makes a run reproducible: it pins every container image to a content digest before running, and writes a static manifest recording exactly what ran – resolved inputs and outputs, the backend, and the pinned image digest of each step.
It is opt-in and off by default, because pinning changes how containers execute (see Pin-then-run below). Enable it per invocation, per call, or in config:
$ ninja run myrecipe.py:selfcal --ms data.ms --provenance
result = my_step(ms="data.ms", provenance=True) # StepRef call
# ~/.shinobi/config.yml
provenance:
enabled: true
dir: ".shinobi/runs"
When enabled, two things happen together: images are digest-pinned, and one run manifest is written per top-level run.
Pin-then-run¶
With provenance on, a container image is resolved to its registry digest
before it runs, and the executed reference is rewritten to
repo@sha256:.... What executes is therefore exactly what the manifest
records – a floating tag like :latest can’t drift between the record and
the run.
This is a genuine behaviour change, which is why it is opt-in:
the run executes
repo@sha256:...instead ofrepo:tag;it needs a registry round-trip to resolve the digest before running;
if your local
:latestdiffers from the registry’s, the registry image is what runs.
With provenance off (the default), images run by their original tag with no registry round-trip – exactly as a plain container run always has.
The digest is resolved best-effort, in order:
a built-in, dependency-free registry API client (reads the manifest digest over HTTPS; uses credentials from
~/.docker/config.jsonfor private repositories);skopeo inspect, if installed;docker buildx imagetools inspect, for the docker/podman runtimes.
A local .sif file is content-hashed directly. An image that can’t be
resolved (a local-only build, an offline host) runs unpinned, and the
manifest reports that honestly rather than inventing a digest.
The run manifest¶
One manifest is written per top-level run to provenance.dir
(default .shinobi/runs), named <name>.<utc-timestamp>.<pid>.run.json.
It freezes the resolved run as a tree of steps:
{
"schema_version": 1,
"shinobi_version": "0.1.0b1",
"target": "myrecipe.py:selfcal",
"generated_at": "2026-07-13T14:07:50Z",
"backend": "docker",
"returncode": 0,
"root": {
"name": "image",
"kind": "cab",
"backend": "docker",
"image": "quay.io/stimela/wsclean:latest",
"image_digest": "sha256:6baf435...",
"containerized": true,
"sandboxed": false,
"inputs": { "ms": "data.ms", "prefix": "out" },
"outputs": { "image": "out-MFS-image.fits" },
"steps": []
},
"pinned": true
}
A recipe’s sub-steps appear, in declaration order, under steps.
pinnedtrueonly when every step that ran inside a container resolved to a digest and no step ran in a venv. A containerized step that couldn’t be pinned – including a Slurm job running under apptainer – makes itfalse, as does anyvenvstep. A consumer can refuse to treat a manifest withpinned: falseas reproducible.Provenance durability is tiered, and
pinnedis a claim about container image digests, not cross-machine reproducibility in general:docker/podman/apptainerwith provenance enabled – durable: the exact image digest that ran is recorded and re-run.venv– a version-parity record only:venv_digestis a hash of the venv’sname==versionlist, which is not an OS-level pin (identical version lists can sit on different compiled C-extensions), so a venv step is always reported unpinned.native– no environment provenance at all: animageis mere metadata, so a native step never dragspinnedfalse, but its vacuouspinned: trueis not a reproducibility guarantee.
image_digestThe
sha256:...that actually ran, ornullwhen unpinned.venv/venv_digestThe virtualenv a
venv-backend step ran in, and asha256of itsname==versiondistribution list (recorded only under provenance). The digest is informational – a venv step is always unpinned (seepinnedabove), so replaying a manifest that contains one is refused unless--allow-unpinnedis passed, exactly like a digest-less container step.sandboxedtruewhen the step ran with per-step sandbox execution enabled (see Sandboxed execution), otherwisefalse. This is recorded for diagnostics; it does not affect whether a manifest can be replayed.skippedtruefor a loop iteration that ran after the loop had already converged, so it passed the previous iteration’s outputs through instead of doing work. This is what makes the manifest an exact record of how many cycles a run actually performed – the graph, and--dryrun, only show how many were declared. Distinct fromcached: nothing was looked up, andkindstill reports what the step is.targetThe CLI target string (
path/to/file.py:nameorpkg.mod:name) that produced the run, recorded so ninja replay can find the recipe again.nullfor programmatic runs (aStepRefcalled from Python) and for manifests written before the field existed.
stimela_version / cab_repo_commit are reserved and currently null.
Replaying a run¶
A manifest is not just a record – it can be re-run:
$ ninja replay .shinobi/runs/selfcal.20260713T140750Z.12345.run.json
Replay loads the manifest’s target, forces every containerized step’s
image to the recorded repo@sha256:... (an already-pinned reference passes
through pin-then-run with no registry round-trip), and re-runs with the
recorded inputs on the recorded backend. The replay is itself a provenance
run, so it writes a fresh manifest of what it ran.
What replay guarantees – and what it refuses:
Declared resources are recorded, not restored. Each step’s
resourcesfootprint (see Recipes) is written into the manifest, because a barereturncode -9months later tells you nothing while-9besidememory=200GiBis a diagnosis. Replay deliberately does not re-apply it: a footprint describes the machine a run happened on, not the run itself, so replaying elsewhere uses that machine’s own declaration.Unpinned manifests are refused. A manifest with
pinned: falsecannot promise the same images run again, so replay errors, naming the unpinned steps;--allow-unpinnedproceeds anyway, running those steps by their original reference.A changed recipe is an error. Manifest steps are matched to the recipe’s steps by name; a step that has since been removed, renamed, or added makes replay refuse rather than run something other than what the manifest froze. This also means a failed or interrupted run (whose manifest omits never-reached steps) cannot be replayed exactly.
The source still matters. The manifest pins images and inputs, not code: replay re-imports the target file, so it reproduces the original run only against the same checkout (the reserved
cab_repo_commitfield is where that will eventually be recorded). Orchestration functions (@shinobi.stepbodies) re-execute; any nondeterminism inside them is outside the manifest’s guarantee.Lossy inputs may not replay. Non-serializable inputs (e.g. a MUTABLE field holding a live Python object) are stored in the manifest as strings; if they no longer validate against the recipe’s inputs model, replay reports that rather than guessing.
--target supplies the target for manifests that don’t record one, and the
global ninja --backend flag overrides the recorded backend (e.g. when
replaying a Slurm run on a laptop with docker).
Cleaning up¶
Manifests accumulate under provenance.dir. Remove them (and the step
cache) with ninja clean:
$ ninja clean --no-cache # just the run manifests
$ ninja clean --dry-run # preview without deleting