Provenance
Provenance is the record of where something came from: who made it, what it was made from, and how. A reader deciding whether to trust a figure is really asking a chain of these questions, and the answer has to hold at every link or the chain doesn't hold at all.
The pipeline answers most of this for you
If a stage produced a file, its provenance is already written down. The stage
names the command that ran, the inputs it read, and the environment it ran
in, and calkit run records what actually happened in dvc.lock. Nothing
further is needed, and nothing should be written twice.
The gap is everything that arrives some other way:
- data somebody measured or typed in
- a file downloaded from a website or an archive
- a photograph of the apparatus
- a diagram drawn by hand, or by a generative AI tool
- a script or config a colleague sent over
These sit at the bottom of the chain. Every automated check above them passes, because there is nothing to check, and that is exactly what makes them easy to overlook: the project looks fully reproducible right up until someone asks where the raw numbers came from.
Data
A dataset records how it entered the project. See datasets for the full set of forms, in short:
datasets:
# Collected for this project
- path: data/raw.csv
created_by:
email: me@myorg.edu
orcid: 0000-0002-1825-0097
# Obtained from elsewhere
- path: data/published.csv
imported_from:
doi: 10.5281/zenodo.1234567
Everything else
Most files are neither a dataset nor a figure nor a paper. Those go in
misc, which takes the same imported_from forms plus who made it:
misc:
- path: img/test-rig.jpg
title: Photo of the test rig
created_by:
email: me@myorg.edu
- path: cfg/solver.toml
imported_from:
url: https://someone.org/solver.toml
date: 2026-01-02
Scripts count. A figure resting on a script nobody will claim has just as little behind it as one resting on data nobody collected.
Bringing a file in from elsewhere
calkit import path copies a file into the project and writes the entry
for you, which is the usual way one of these gets recorded. A link copied
out of the browser is enough:
A GitHub or GitLab link to a file is read as the Git source it actually
is, rather than as an HTML page that happens to contain the file: the
repo, the branch, and the path within it all come out of the URL.
A link that names no revision, including one written out by hand as
https://github.com/myorg/setups/setups/setup.sh, takes the default
branch.
An SSH clone URL works the same way, since that is the thing most often
copied out of a forge:
The repo is taken to be the first two segments after the host, which is right for GitHub and for GitLab projects outside nested groups. For anything else---or for a repo Calkit can't read a URL for at all---name the repo outright:
calkit import path setups/setup.sh scripts/setup.sh \
--git-repo https://github.com/myorg/setups.git --git-ref main
A branch whose name contains a slash, like feature/foo, needs no special
handling: a URL doesn't say where such a branch ends and the path begins,
so the split is checked against the repo when the file is fetched and
corrected if the guess was wrong. --git-ref still overrides it.
Other addresses work too, and are read as what they are:
| Written as | Recorded as |
|---|---|
| A GitHub or GitLab link to a file | git_repo_url, with git_ref and path |
| Any other URL | url |
A DOI, bare or as a doi.org link |
doi |
someone/some-project/path/to/file |
project |
A DOI is recognized rather than downloaded, since it resolves to a
landing page: saving that HTML and calling it the data is the mistake
worth refusing. Use
calkit import zenodo for a Zenodo record.
It lands in misc unless --kind says otherwise, and the entry records
the commit the file actually came from, even when a branch was named:
misc:
- path: scripts/setup.sh
imported_from:
git_repo_url: https://github.com/myorg/setups.git
path: setups/setup.sh
git_ref: main
The copy is committed here rather than fetched on demand, so the project stays self-contained and a pipeline stage can depend on the file like any other input.
To pick up later changes:
That takes the latest of whatever the entry follows---its git_ref if it
names one, and the repo's default branch otherwise---and records the
commit it lands on in the lock file.
So git_ref is the question and the recorded commit is the answer:
reading the answer back would make refreshing a no-op.
--git-ref changes what an entry follows, from then on rather than just
this once:
A git_ref naming a commit rather than a branch is a fixed point, so an
import pinned that way---including one taken from a link to a file at a
particular commit---stays where it is when refreshed.
Either way this is a one-way copy from the source, not a merge: local
changes to the file are discarded. An import is a claim about where the
bytes came from, and an edit that survived would make that claim false.
If the file needs to differ here, it isn't imported---drop the
imported_from and record who changed it instead.
To see everything a project took from elsewhere, whichever list it was recorded in:
And to refresh all of them at once:
An entry that can't be refreshed in place---a dataset tracked by DVC, or a record named only by a DOI---is reported and skipped rather than stopping the rest, as is one whose source can't be reached, since a repo being down shouldn't leave every other import stale. The command exits non-zero when anything was skipped, so a script notices.
What goes where
calkit.yaml records what a person declared---the repo, the path within
it, and the git_ref to follow. What a fetch resolved to goes in
.calkit/imports.json, keyed by path:
{
"scripts/setup.sh": {
"fetched": "2026-08-31T23:14:21+00:00",
"rev": "4fadbcf62125c19c9cbf31de60831f656ffe5d4e",
"hash": "sha256:9f2b..."
}
}
That file is committed, so everyone cloning the project gets the same
bytes, the way they do from dvc.lock. Everything Calkit keeps under
.calkit is managed by Calkit---read and write it through the CLI, the
web app, or the extension rather than by hand.
To pin an import, write the commit hash as its git_ref. A commit is a thing
to follow that happens never to move, so no separate field is needed and
calkit.yaml stays a statement of intent.
The recorded checksum is what makes a local edit visible. Refreshing a
file that has been changed since it was fetched would discard that work,
so it is reported and refused until --force says otherwise:
calkit sync import scripts/setup.sh
# Error: 'scripts/setup.sh' has been edited since it was imported ...
An entry written before this split carries its git_rev in
calkit.yaml.
It is still read, and refreshing the import moves it across, so nothing
has to be migrated by hand.
created_by is the same key for all of them, whether the work was
collecting data, drawing a diagram, or taking a photograph, and it takes a
list, since work usually has more than one person behind it. Each entry
needs an email or an ORCID.
A name on its own doesn't say which of the several people with that name
this is, so credit rests on something resolvable.
Figures
Most figures come out of a pipeline stage, which already says how they were
made. A figure that doesn't -- a schematic drawn by hand, a diagram laid out
with a generative AI tool -- has no stage to point at, so it takes
attribution the same way misc does:
figures:
- path: figures/cp-curve.png
title: Power coefficient
stage: plot-cp # Made by the pipeline; nothing more to record
- path: figures/schematic.png
title: Apparatus schematic
created_by:
email: me@myorg.edu
A figure obtained from elsewhere takes the same imported_from forms a
dataset does, and publications do too; created_by and imported_from
can't both be set on one entry.
Drafting a figure in the browser
The hub's figure studio runs Python in the browser, so a plot can be
iterated on before anything is installed. A run there proves nothing about
reproducibility, and the studio doesn't pretend otherwise: nothing is
recorded until you save, and saving commits the script, declares a
python-script stage that reads the data and writes the figure, and creates
a Python environment for the stage if the project has none. From then on the
figure is a pipeline output like any other, and the real provenance is the
stage's next run on a real environment, not the preview you saw in the
browser.
Disclosing generative AI
If a generative AI tool helped produce a figure or a misc artifact, the
person who used it says so with with_ai:
figures:
- path: figures/schematic.png
created_by:
email: me@myorg.edu
with_ai: Claude Opus 5 # Can be a list too
The disclosure sits inside the person rather than beside them, so it can't
exist without someone answering for it. A model can't be responsible for a
file; created_by says who was, and with_ai says what they used. With
several authors, it also records which of them used the tool:
misc:
- path: figures/composite.drawio
created_by:
- email: me@myorg.edu
with_ai: Claude Opus 5
- orcid: 0000-0001-5109-3700
The point isn't that generative AI is disqualifying. It's that whether its use was appropriate depends entirely on what the file is, and a reader can only make that call if they're told. A schematic laid out by a model is usually unremarkable.
On a dataset, it's a question to answer
with_ai can be recorded anywhere a person can, datasets included. That is
deliberate: a rule against writing it down doesn't stop anyone using a
model, it only stops readers finding out.
But it should read as a flag rather than a footnote. A schematic laid out
with a model is usually unremarkable. Data is not. A dataset is either
measured, or obtained from somewhere, or computed by the pipeline from
things that were, and a model produced none of those: there is no
measurement behind it and no derivation to check. Seeing with_ai on
a dataset's created_by is a reason to ask exactly what the tool did, and
to expect a specific answer.
Often the honest answer moves the record somewhere better. If a model
generated the data itself -- synthetic training data, say -- then a pipeline
stage made it, and the stage records the command, the inputs, and the
environment, which is a far stronger account than any disclosure written by
hand. If the model only transcribed handwritten sheets or reshaped a file,
say so in the dataset's description, where the reader is already looking.
What a declaration is worth
Everything on this page is hand-written into calkit.yaml, and writing
something down doesn't make it so. A declaration is the weakest link in a
provenance chain: nothing verifies it.
That's not an argument against recording it, since the alternative is recording nothing. But it does mean an origin a reader can go and fetch beats an attestation whenever one exists. Prefer, in order:
- A DOI, which resolves and is citable
- A Git repo at a specific commit, which names exact bytes. A repo and
path with no revision names whatever is there today, which is a mutable
claim dressed as a citation, so
git_revmust be a commit hash rather than a branch or tag - A URL, optionally with the date it was retrieved
- An attestation that someone collected or created it, when there's genuinely nothing to point at