Design: start here#
Note
The entry point to the design documents. It is written for someone, or some agent, who has never seen this project and has to continue building it. It says what ChartBook is, the words it uses, what is built, what to build next and in what order, and where every decision is written down.
Last revised 2026-10-12 (Versions, 2026-10-18), after the 0.2.0 prepared that day and never published, the index, declared models, and all five parts
of step 3: declared dependencies, the store, pinning, references to another pipeline’s
assets, and git dependencies. Step 3 is built. The next step is 4, running and build
records.
What ChartBook is#
ChartBook is a Python command-line tool and library. A data project describes what it produces
in one file, chartbook.toml, and ChartBook turns that description into a documentation
website, a way to load the data by name, and (the work ahead) a record of what depends on what.
Three sentences hold most of the design:
The manifest is the contract.
chartbook.tomlsays what exists and where. ChartBook reads it; it never requires your code to call ChartBook.ChartBook publishes; it never orchestrates. It does not schedule, retry or run your analysis. Your own tool (
doit,make, a shell script) does that.Everything works for one person on one laptop with no server. Sharing is the same files, in git or on a shared drive.
It is experimental and pre-1.0. Breaking changes are allowed, with a changelog entry that says what to do instead (Stability).
The picture#
organization (scope) ftsfr
pipeline ftsfr/fed_yield_curve one repository, one chartbook.toml
dataframe …:treasury_rates a parquet file (later: a table)
series …:treasury_rates#dgs10 one column of it
chart …:chart/curve_today an HTML file
notebook …:notebook/analysis
note …:note/methodology a page of prose
model …:model/rate_forecaster a trained model
catalog many pipelines, browsed and searched as one site
Data is the spine. Organization, pipeline, dataframe, series: that chain is what people load, depend on and cite. Charts, notebooks, notes and models hang off a pipeline and say what kind they are. This was the maintainer’s explicit correction to an earlier draft that treated all assets alike.
Words#
The vocabulary is a page of the documentation, Vocabulary, and that page is the one place the words are defined; the design documents use them in those senses. The sentence to keep in mind, the maintainer’s (2026-10-14): a manifest asks, a lockfile records what was got, a snapshot records what was made, and an index records what a site shows. The chain is repository → index → pages → site, with a writer per site tool and the site tools called by their names.
Rules that settle arguments#
These come from the maintainer and from Design: the asset graph. When a design question is close, they decide it.
Scale to zero. No capability may require a running service to be useful to one person on one machine.
A convenience is never a requirement.
data.path()is nice. A script that hardcodes the same path, in any language, must keep working.No orchestration. No scheduler, retries, backfills, run history or run UI. Export the graph to a tool that has them.
No plugin system before a second implementation exists. Leave one internal seam. Write the second implementation. Then extract the interface.
Some things are singular. Hash computation, name resolution and the index schema each have exactly one implementation, on purpose.
No features for machines alone. Anything built for programs must fall out of something that already helps a person.
Refuse, do not guess. Syntax that is reserved for later raises “not yet supported”. It never quietly does something else.
Plain files stay plain. A declared
pathis the real location of the real file. The store links to it; it never moves it.
The full list of things that will be declined is “Not doing” in the asset graph. Read it before proposing a feature.
Versions#
Decided by the maintainer, 2026-10-12.
The asset graph is one change, not a release per step. The steps under “What to build next” do not each get a minor version. Do not bump the version in a pull request.
0.2.0 is the release. The asset graph, the store, the versioned catalog and the review before the release ship together as 0.2.0, the first release since 0.1.1 (decided 2026-10-18; the 0.2.0 prepared on 2026-10-12 was never published). Until it is published, what lands goes under “Unreleased” in the changelog.
1.0 is a long way off, on purpose: there is room for many more changes before any promise of stability. Nothing here should be planned as “the last thing before 1.0”.
The documents, and the order to read them#
Order |
Document |
What it settles |
State |
|---|---|---|---|
1 |
This page |
The map |
|
2 |
The manifest: tables, fields, pipeline IDs, catalogs, policy |
Built |
|
3 |
How an asset is addressed |
Parser built. Local references resolve live; another pipeline’s resolve pinned |
|
4 |
Dependencies, pinning, the store, build records, what ChartBook will not do |
Step 3 built: declared dependencies, the store, pinning, cross-pipeline references, |
|
5 |
The index file, record by record |
First implementation built |
|
6 |
Models as assets |
First slice built: declared models |
|
7 |
Dataframes that live in a database |
Built for DuckDB, 2026-10-13: |
|
8 |
The store as a setting of the machine; stores and backends as plugins |
Settings layers, the store interface and the S3 store built 2026-10-16 |
|
9 |
Index and snapshot settled; a catalog of every version of every pipeline |
Step 1, the self-sufficient snapshot, built 2026-10-16 |
|
10 |
What a build of a folder on this machine may do before you have trusted the pipeline, and how you say so |
Built 2026-10-18: |
For how the existing build works, read How the Build Works. For every command and field, CLI Reference and Configuration Guide.
What is built#
As of 0.1.1 and the 0.2.0 prepared on 2026-10-12 (never published), plus the index, declared models, declared dependencies, the store, pinning,
named scripts, chartbook run and snapshots, which came after it.
Capability |
Where |
Notes |
|---|---|---|
Manifest format v2, pipeline IDs, catalogs, policy |
|
|
The documentation site |
|
Two stages: write Markdown into |
Incremental builds and |
|
A state file of file sizes and times. No server. Catalog builds are incremental by default |
|
|
Required fields, broken paths, expiring licenses. |
The reference grammar |
|
All of it parses |
Resolving local references |
|
One resolver, typed locations. Live: the declared |
|
|
Cloned into |
Named scripts and |
|
|
Resolving another pipeline’s assets |
|
|
The index, |
|
Written by every build, beside the HTML. One hash rule; hashes cached in the build directory |
Series tables |
|
|
Models |
|
|
Declared dependencies |
|
|
The store |
|
Objects named by content hash, behind |
Snapshots |
|
What one commit of a pipeline built, in the store. Made on purpose by its owner, with everything committed. The only thing of a pipeline that another reads. Since 2026-10-16 it holds the version’s own index and documents, and |
Pinning |
|
|
Loading a dataframe through a catalog |
|
|
Scratch directory |
|
|
Freshness lines on pages |
|
“Data updated 2 days ago”, computed in the browser |
Data glimpses |
|
Reads the manifest, not |
Scaffolding |
|
Renders the cookiecutter template, which is the |
Plotting helpers and backends |
|
The one real plugin system. See Writing a Plotting Backend |
Reserved for later, and refused |
|
|
Not built: any export, and anything that reads the index. Not to be built: loading one column by its
address (data.load("dataframe#series")).
What to build next, in order#
Who decided what
The maintainer has decided the destination (everything in Design: the asset graph, Design: the index, Design: models and Design: data access) and that the index comes first. The order of the steps after the first, and their boundaries, are the recommendation of the last person to work on this. Change them if a better cut appears, and say so here.
Each step ends with something a user can run. Do them one at a time, each as its own branch and pull request.
Step 1. The index — built#
chartbook docs also writes one JSON file that describes everything: index.json, beside the
HTML. It has Catalog, Pipeline, Dataframe, Series, Chart, Notebook and Note
records, each asset with a fully qualified id, a structured ref and a content hash. The code
is chartbook/index.py and chartbook/hashing.py; the JSON Schemas are in chartbook/schemas/;
the loader reads [dataframes.x.series.y].
Design: the index is the specification, and its last section, “What the first implementation does”, says exactly what is written and what is not. Nine things were settled while building it. The maintainer has decided three; six are listed there as proposed, still to confirm.
Left for later steps, on purpose: Build records, dependencies, Model records, and
rendering the site from the index.
Step 2. Declared models — built#
A pipeline can declare a trained model in [models.x], ask for its path
(chartbook path model/x, chartbook.models.path("x")), check it, and get a page and a Model
record in the index. This is slice M1 of Design: models; its section “What is built” lists every
file involved and eight things that were settled while building it, all proposed.
Left for later steps, on purpose: versions and labels (@v3, @champion), parts
(model/x#file), and anything that crosses a pipeline boundary.
Step 3. Dependencies, the lockfile and the store#
Goal: data.path("fed_yield_curve:treasury_rates") works, and returns a frozen copy.
(Met 2026-10-12, with 3d.)
The case this step is for
A model in one pipeline, trained on data from another, reproducibly. The maintainer was explicit: cross-pipeline training data is what ChartBook is going for, not a later refinement, and it is one reason all of this is being built as one change with no release in between.
Today a model can state that it was trained on another pipeline’s dataframe (Design: models). Nothing fetches that data, pins a version of it, or records which bytes were used. This step makes it real. Judge every design choice in it against that case first, and against “a chart of another pipeline’s data” second.
It is the largest step, and the first where the design has never met code. Build it as five
pull requests, in this order. Each leaves main working.
Pull request |
What a user gets |
What it does not do |
|
|---|---|---|---|
3a (built) |
Declared dependencies. |
A pipeline says what it depends on, and |
Nothing is fetched, copied or pinned |
3b (built) |
The store. Objects named by content hash, behind four operations (has, get, put, delete). Links, not copies: reflink, hardlink, symlink, copy, in that order. |
Nothing visible. This is the piece everything after sits on, and it can be tested alone |
Nothing uses it yet |
3c (built) |
Pinning. |
A project can freeze what it depends on, and see in a diff when that changes |
References still do not resolve across pipelines |
3d (built) |
Cross-pipeline references resolve. |
The payoff. The case in the box above works end to end |
|
3e (built) |
|
A dependency that is not on your disk |
— |
Done when: examples/rate_forecast builds from a clean clone with no catalog configured, its
model’s index record names the exact bytes of the dataframe it was trained on, and editing the
upstream’s data does not change what the downstream reads until chartbook update.
Settled with the maintainer, 2026-10-12: where the store lives on disk and its layout;
how it links to a file; the lockfile’s shape; what pin does when an upstream has not been
built; and which hidden files a directory’s hash skips. Still to settle: how a git
dependency is fetched and where its checkout is kept (before 3e), and whether
data.load("pipeline:asset") arrives in 3d. All seven are under “Answers to the open
questions” below.
3a, as built#
Added 2026-10-12. What a user can do: declare [dependencies], run chartbook check, and be
told whether each one is there and whether it declares the training data a model names.
[dependencies]
"ftsfr/fed_yield_curve" = { path = "../fed_yield_curve" }
[models.rate_forecaster]
path = "./_models/rate_forecaster.joblib"
trained_on = ["fed_yield_curve:treasury_rates"]
What |
Where |
|---|---|
The |
|
The resolution chain, and the one place a pipeline’s name becomes a location |
|
Matching a name against a set of pipeline IDs, without failing on a miss |
|
|
|
|
|
The warning for a |
|
The tests |
|
Everything that used to look a pipeline up by name now calls locate_pipeline(): the catalog
lookups behind data.load(pipeline=…), the index, the model page and check.
Not done in 3a, on purpose. Nothing is fetched, copied or pinned. pipeline:asset is still
refused wherever a path is asked for: it must resolve to a frozen copy, never to the other
pipeline’s working directory, so making it resolve early would change what the same text means
later. No example pipeline declares a dependency yet; examples/rate_forecast arrives in 3d.
Pages do not list a pipeline’s dependencies.
Proposed: implemented in 3a, and waiting on the maintainer’s yes. Each was open or unstated in the design.
A
gitdependency may be declared now, though nothing fetches it until 3e. It is validated, recorded in the index, andcheckwarns that it was not looked at. (The table above says “pathfirst”. Refusinggituntil 3e was the other option; the design’s own example manifest would not have loaded.)revis optional on agitdependency. With none, the revision is whatever the repository’s default branch has when it is pinned, as apathis whatever the directory holds. What that fetch does is 3e’s to settle.{ rev = "…" }with nogitis refused, with a message. Design: chartbook.toml Format v2 shows that form, which needs[resolve] default_hostto work out the URL. Not built.The key of
[dependencies]is a plain pipeline ID. No URL, and no@rev: a revision goes in the entry. Design: references said such a key may carry both.A reference may leave the scope off when exactly one dependency has that name, as a catalog lookup allows. A name that fits two dependencies is an error, and is not passed on to the catalog to be guessed at.
A
trained_onthat names an undeclared pipeline warns when the manifest loads, sobuildshows it as well ascheck, andcheckdoes not fail on it. It becomes an error in 3d.checkneeds apathdependency to be on disk, with--skip-outputstoo. A dependency is not something this pipeline builds. A project whose CI checks out one repository will wantgitdependencies, which is 3e.Within one build, a pipeline goes by the build’s name for it. If a pipeline calls
../fed_yield_curveyield_curveand the catalog calls that directoryftsfr/fed_yield_curve, the catalog’s index saysftsfr/fed_yield_curvein bothdependenciesandinputs. Alone, the pipeline’s index saysyield_curve.One ID for two directories is reported by
checkon a catalog, when a dependency’s key is a catalog pipeline’s ID and itspathis a different directory. Inside one pipeline the case cannot arise (a TOML table has each key once), and two pipelines of a catalog may point one name at different places without it being reported.A catalog with a
[dependencies]table does not load. It reads from no pipeline.An unknown key in an entry is an error, not a warning, so that a later key (
branch,tag) is never silently ignored by an older ChartBook.The index’s shape for a dependency: see Design: the index, “Decided while building it”, items 10 to 13.
One row of the table above cannot be met as written. “A cross-pipeline trained_on or
chart input must name a declared dependency.” A chart has no way to name another pipeline’s
dataframe: its dataframe field must be one this pipeline declares, or the manifest does not
load. So only trained_on is covered. A chart of another pipeline’s data needs the chart’s
inputs field (Design: the index) or a looser dataframe, and a page that can render it;
that is best decided with 3d.
3b, as built#
Added 2026-10-12. Nothing a user sees, apart from one command. The store is the piece that pinning sits on, and it is tested alone.
from chartbook.store import FolderStore
store = FolderStore("~/.chartbook/store")
stored = store.put("_data/treasury_rates.parquet") # the file stays where it is
stored.content_hash # "sha256:7f3a9c…", the object's name
store.get(stored.content_hash) # …/objects/sha256/7f/3a9c…/
store.has(stored.content_hash), store.delete(stored.content_hash)
$ chartbook store verify
/Users/x/.chartbook/store: OK (14 object(s))
What |
Where |
|---|---|
The store: |
|
The four kinds of link, and the one place they are tried in order |
|
Where the store is: |
|
|
|
Which files make up an asset, for hashing and storing alike; the files that never do |
|
The tests |
|
Not done in 3b, on purpose. Nothing puts anything in the store: no pin, no lockfile, no
gc. builds/, the index from a build’s inputs to its output, is a name the layout keeps
free and nothing writes to. The link kinds cannot be chosen in settings.toml; a caller in
code can pass its own list.
Proposed: implemented in 3b, and waiting on the maintainer’s yes.
An object is hashed after it is stored, not before.
putlinks or copies the file into the store first and then hashes what the store holds. So an object’s name is right even if the original was being rewritten while it was stored.A clone or a copy is made read-only in the store. A hard link is not, because it cannot be: see the first correction below.
puttakes the kinds of link to try. The default is the designed four. A caller that must have a frozen object passeslinks=("reflink", "copy"), and what comes back says whether it is frozen (stored.frozen). So 3c can tell a user, when it pins, how many of the pins are links to files that can still change. Nothing chooses for the user.The same bytes under another file name are one object with both names in it. A file’s hash does not include its name, and a path into the store has to end in the right one.
An empty directory is not stored. There is nothing to pin, and its hash would be the hash of an empty file.
The store says which layout it has, in
store.json. A store laid out by a later ChartBook is refused, not misread.A store comes into being when something is first put in it, never by being looked at.
chartbook store verifyon a machine with no store says “OK (0 object(s))” and creates nothing.An object is built in
tmp/and moved into place with one rename, so another process sees all of it or none of it, and two that store the same content at once end with one object.verifyexits 1 if any object has changed, and printshash: what is wrong. It ignores the same operating-system files that hashing ignores, so Finder looking inside the store does no harm.A symlink in the store points at the real file, with any links on the way resolved, so the store never holds a link to a link.
Where the design was wrong about what a disk does. Both are in Design: the asset graph, “Links, not copies”, and are marked there.
“Hardlink: make the store side read-only.” A hard link has no store side. Both names are one file with one set of permissions, so making it read-only makes the upstream’s own file read-only, and its next build fails on a file it wrote itself. That would break rule 2, “a pipeline that hardcodes its paths keeps working”. So a hard-linked object is left writable, and
verifyis what protects it.“The copy rung will be exercised: a shared network mount is a different filesystem, so neither reflinks nor hardlinks reach it.” A symlink comes before copy in the ladder, and a symlink does reach across filesystems. So with the store on one disk and the data on another, an object is a symlink, never a copy. Copy is reached only where symlinks are not allowed, which in practice is Windows without the privilege.
3c, as built#
Added 2026-10-12. What a user can do: freeze what a pipeline depends on, see in a diff when that changes, and clear out of the store what nothing needs any more.
$ chartbook pin
ftsfr/fed_yield_curve: 3 pinned, 1 not built
not built: ftsfr/fed_yield_curve:model/term_premium_ar1 (./_models/term_premium_ar1.json)
Wrote chartbook.lock.
$ chartbook update fed_yield_curve
ftsfr/fed_yield_curve: 1 moved, 2 already pinned, 1 not built
ftsfr/fed_yield_curve:treasury_rates
sha256:4b1f… -> sha256:99ab…
Wrote chartbook.lock.
What |
Where |
|---|---|
Reading and writing |
|
The lockfile’s JSON Schema |
|
Which lockfiles name the store’s objects |
|
Every asset a pipeline declares with a |
|
|
|
The tests |
|
Decided by the maintainer, 2026-10-12, while it was being built:
pinnever moves a pin;updatedoes. A secondpinpins what has no pin yet and leaves the rest exactly where it is, saying what has changed upstream.chartbook store gckeeps what registered lockfiles name, and asks before removing anything. Eachpinandupdatenotes its lockfile in the store. A lockfile that cannot be found still keeps its pins unless--forget-missingis given.--dry-runonly lists;--yesdoes not ask.
Not done in 3c, on purpose. Nothing reads the lockfile to resolve a reference:
chartbook path fed_yield_curve:treasury_rates is still refused, and the index does not carry
pinned_hash. Both are 3d. chartbook check says nothing about a lockfile that is out of
date; refusing to run with one is --frozen, in step 4. A git dependency is listed and not
pinned.
Proposed: implemented in 3c, and waiting on the maintainer’s yes.
The lockfile leaves out
generatoras well asgenerated_at. The decision was “no timestamp”. Without the ChartBook version too, upgrading ChartBook does not rewrite every lockfile.The lockfile is keyed by the dependency’s key in
[dependencies], the pipeline’s own name for it, not by what the other pipeline calls itself.Every kind of asset is pinned: dataframes, charts, notebooks, notes and models. The plan says “declared assets”, and with links it costs nothing.
A dependency’s
pinned_hashis the hash of a listing of its pins, one line per pinned asset, in the form a directory is hashed in. One line of a diff then says a dependency moved.pinputs back what the store has lost, when the other pipeline still has the same bytes. That is what makes it the command to run after cloning. When the bytes are gone the asset is reported as lost andpinexits 1; the pin is not moved to what is there now.pinhashes what is already pinned, to say what has changed upstream. It reads every byte of every pinned asset on each run. A remembered hash would make that cheap, and is not built.An asset the other pipeline stops declaring keeps its pin under
pin, and is dropped byupdate. One it declares and no longer has on disk keeps its pin under both. (2026-10-13, proposed: one exception, for agitdependency that an update moves to another commit. See “4a, as built”, number 19.)A pinned dependency whose directory has gone is fine for
pin. The pins are in the store, which is the point. Forupdateit is an error.No lockfile is made for a pipeline with no dependencies. Removing the last one leaves a lockfile with nothing in it.
pinreads only the pipeline’s ID and its[dependencies], so something wrong elsewhere in the manifest does not stop it.pinsays when what it stored is held as links to the other pipeline’s files, and points atchartbook store verify.gcleaves alone anything stored in the last hour. A pin puts files in the store before it writes the lockfile that names them, so agcrunning at the same moment would otherwise remove them.gcreads each lockfile as it is now. After checking out an older commit, the pins of the commit you left are named by nothing until you pin again.gcremoves nothing if a lockfile, or one of the store’s notes, cannot be read.
Where the design was wrong, or assumed something that does not exist yet.
“
chartbook.lockis … its ownBuildrecords and its resolveddependencies” (Design: the index). It leaves out the part that freezes anything: the other pipelines’ asset records, with their hashes. Those are what the lockfile mostly holds.“Both use the same record schemas.” True of the asset records, which fit the index’s schema for their kind as they are. The lockfile’s
Pipelinerecord has onlyidanddependencies, and the index’s schema for aPipelinealso requiresname,locationandassets, which would change the lockfile every time the manifest did. The lockfile has an envelope schema of its own that says so.“
chartbook runpins anything not yet pinned, so neither verb is needed to get started.” There is norununtil step 4, so until thenpinis how to get started.“
chartbook update… and rebuild what that invalidates.” That also needsrun.updatemoves pins and rebuilds nothing.“An object is reachable if some lockfile references it.” With one store for all of a user’s projects, the store has to be told which lockfiles there are. It keeps a note of each one that pins into it.
(“Done when” is met, 2026-10-12, for path dependencies.
tests/test_integration.py::test_rate_forecast_example is that sentence as a test: it builds
the example with an empty store and no catalog, reads the model’s pinned_inputs from
index.json and checks it against the bytes, rewrites the upstream’s parquet file in place
with pandas, and checks that the downstream still reads the old bytes until
chartbook update. “From a clean clone” is true of the code; the example’s upstream pulls its
data from the network.)
3d, as built#
Added 2026-10-12. The payoff. A pipeline reads another pipeline’s asset by name and gets the pinned copy.
from chartbook import data
rates = data.load("fed_yield_curve:treasury_rates") # theirs, as pinned
mine = data.load("merged_rates") # mine, as it is now
$ chartbook path fed_yield_curve:treasury_rates
/Users/x/.chartbook/store/objects/sha256/4b/1f…/treasury_rates.parquet
What |
Where |
|---|---|
A reference to another pipeline’s asset: |
|
The two errors that say what to add or run |
|
|
|
Pins are a clone or a copy; an object that was a link is replaced |
|
|
|
An undeclared pipeline in |
|
The second example |
|
The tests |
|
Decided by the maintainer, 2026-10-12, while it was being built:
A pin is frozen.
pinandupdateask the store for a reflink, then a copy, and never a hard link or a symlink. The store keeps all four kinds for what is cheap to make again. This settles the conflict set out under answer 2 below: the “Done when” line holds on every disk, and a pin on a disk without reflinks costs the file’s size.data.load()takes one reference, beside the two-argument form, which still means “through a catalog”.A model’s record names the bytes in a field beside
inputs,pinned_inputs: each cross-pipeline input’s ID and the hash the lockfile pins it at.inputsstays a list of IDs.The example’s lockfile is git-ignored, because its upstream pulls live data. Its build runs
chartbook pin. Its README says a real project commits the file.
Not done in 3d, on purpose. A part (…#dgs10), a version (…@v3) and a registry are
still refused. A git dependency’s assets cannot be read; that is 3e. chartbook check does
not look at the lockfile or the store, since a CI runner has no store. A chart still cannot be
made from another pipeline’s dataframe. Pages do not show what is pinned.
Proposed: implemented in 3d, and waiting on the maintainer’s yes.
What path comes back. For an asset that is a file, the file in the object, under its own name. For a glob, the object’s directory with the glob’s own tail (
…/**/*.parquet). For a directory, the object’s directory.The lockfile lists the files of a pinned directory, as the index lists a directory model’s. That is how a directory is told from a file, by the index’s own rule. A glob is not listed, so a partitioned dataset does not swell the lockfile; a dataframe declared as a bare directory does.
Four ways of not being pinned, each with its own message: no lockfile entry for the dependency, no such asset when it was pinned (with a “did you mean”), not built when it was pinned, and pinned with the bytes not in the store. Each says to run
chartbook pin, and where.An undeclared pipeline is an error that shows the line to add. The user’s catalog is asked where that pipeline is, to fill in the
path. That is the chain’s second step, used for the message only: being in a catalog never lets a pipeline be read from.Resolving stays cheap. It reads the manifest’s
[dependencies]and the lockfile (kept in memory while the file is unchanged), and looks at whether one directory exists in the store. Nothing is hashed. The other pipeline’s own directory is not touched, so it can be moved or gone.data.load()’s arguments. One argument is a reference. With adataframe, the first is the pipeline, as it always was, so no existing call changes. A mix is aTypeErrorthat shows both forms.Pinning replaces an object that is in the store as a link. Something else may have stored the same bytes more cheaply; a pin of them has to be frozen all the same.
pinned_inputsholds only inputs that are another pipeline’s. This pipeline’s own inputs are live, so there is nothing pinned to name. It says what the lockfile pins, not what a given training run read.A lockfile that cannot be read does not stop a build. The index is written without what is pinned, with a warning.
One function says what a manifest may state and what resolves (
resolve.parse_declared_ref). There were two, because another pipeline’s asset could be stated before it could be resolved.parse_local_refis gone.The example retrains when
chartbook.lockchanges. Its training task depends on the lockfile, not on the path of the data. See the first correction below for why.
Where the design was wrong, or did not see something coming.
The
dodo.pyin Design: the asset graph, “Paths”:"file_dep": [data.path("fed_yield_curve:treasury_rates")]. It works, once pinned. But adodo.pyis run from top to bottom before any task starts, so that call fails unless the pin is already there, and a task inside the same file cannot be what makes it. The example depends onchartbook.lockinstead, which exists as a name before it exists as a file.“
path()… reads the rawchartbook.tomland nothing else … it raises only when the name is not declared or its entry has nopath.” True of a local reference. For another pipeline’s asset it also reads the lockfile and looks in the store, and can fail for a reason in either.The link ladder against the step’s own “Done when”. They could not both hold on a disk without reflinks. Resolved by decision 1 above.
The handoff brief: “
doit forget --allin an example forces its tasks to run again.” It does, and the tasks then keep the test suite’s mock data: each pull script skips the download when its data file is newer than itself. A live rebuild needs the examples’_datafolders removed first. Three earlier reports in this series said “live pulls” and were wrong about that.
3e, as built#
Added 2026-10-12. A dependency that is not on your disk.
[dependencies]
"ftsfr/ref_data" = { git = "https://gitlab.example.org/ftsfr/ref_data", rev = "v1.2" }
$ chartbook pin
ftsfr/ref_data: fetched https://gitlab.example.org/ftsfr/ref_data at 4f2a91c (v1.2)
ftsfr/ref_data: 2 pinned, 1 not built
not built: ftsfr/ref_data:daily_prices (./_data/daily_prices.parquet)
A git dependency has only what its repository commits.
Wrote chartbook.lock.
What |
Where |
|---|---|
The clone cache: fetch, resolve a revision, check out a commit |
|
Where the cache is: |
|
Pinning from a checkout, at the locked commit |
|
|
|
The commit, on a dependency in the index |
|
The tests, all on repositories made by the test |
|
Decided by the maintainer, 2026-10-12, before it was built:
Build it now, plainly. Clone, check out the revision, then treat it as a
pathdependency. It pins the declared files that are in the checkout, lists the rest as not built, and says that a git dependency has only what its repository commits. Running the upstream’s build, or a shared store, fills in the rest later with no change to the format.The clones are in
~/.chartbook/git/, beside the store, one per user: a bare clone per repository and a checkout per commit. ChartBook runs the user’s owngit.
Not done in 3e, on purpose. ChartBook does not run a fetched pipeline’s build, so built,
git-ignored data stays “not built”. Submodules and Git LFS are not handled.
[resolve] default_host is not built, so { rev = "…" } with no git is still refused.
Nothing clears old checkouts; chartbook store gc does not touch the git cache.
Proposed: implemented in 3e, and waiting on the maintainer’s yes.
The cache’s layout.
repos/<host>/<owner>/<name>.gitandcheckouts/<host>/<owner>/<name>/<commit>/. A repository on this disk goes underlocal/. The same repository reached by two URLs shares one clone.The lockfile records the commit beside the declared
rev:"location": { "kind": "git", "url": …, "rev": "v1.2", "commit": "4f2a91c0…" }.revis what the manifest says;commitis what it meant when it was pinned. With norev, the repository’s default branch.pinstays at the locked commit, and uses the network only if this disk has lost its copy of it.updatefetches again and asks what therevmeans now, so a branch moves and a tag or a commit does not.A changed
gitorrevin the manifest is followed byupdate, not bypin.pinsays that the manifest now names something else, and stays.A repository that cannot be reached is a failure only when it matters: on an update, or when nothing of it is pinned. With its pins in the store,
pincarries on, as it does for a directory that has gone.A token never reaches a disk or a screen through ChartBook. The URL is handed to git on each call and is not written into the clone’s configuration. Every message, the lockfile and the index show the URL without its user name and password.
Git is never left waiting for someone to type. Prompts are off, so a repository that needs a password ChartBook cannot supply fails.
A URL or a
revthat git could take for an option is refused, when the manifest loads and again before git is run: one that starts with-, arevwith anything but letters, digits,.,_,/and-, and git’sext::transport, which runs a command.A path is a repository too, relative to the manifest:
git = "../ref_data".A checkout is read-only and has no
.gitin it. It is a copy of one commit, not a place to work.checknever uses the network. It looks at agitdependency where it has been fetched, at the lockfile’s commit, and warns when it has not been.Every test runs with its own store and its own git cache. A fixture sets the two environment variables for all of them. (Before it existed, one test run put an empty clone in the real
~/.chartbook/git/. It was removed.)
Where the design was wrong, or said less than it seemed to.
“
pathversusrev: both are pinned. The difference is only where a refresh gets its content from — a local directory, or a git fetch.” (Design: the asset graph.) There is a second difference, and it is the one that matters. A directory has the pipeline’s built files. A git fetch has only what was committed, which for most pipelines is none of the data.“A shipped pipeline is self-resolving. Someone who clones the repository can build it with no catalog configured and no access to your filesystem.” True of its code and of its dependencies’ code. Its dependencies’ data still has to come from somewhere: committed outputs today;
chartbook runor a shared store later.The build record writes a dependency’s location as one string (
"https://gitlab.internal/…@a1b2c3d"), and thePipelinerecord as an object ({ "kind": "git", "url": …, "rev": … }). The lockfile and the index use the object, withcommitadded. Build records should follow when they are built.
Answers to the open questions#
The maintainer answered five of these on 2026-10-12, after 3a, a sixth with 3d and the last with 3e. All seven are decided.
Where the store lives. Decided. One store per user, at
~/.chartbook/store/, beside the settings file that is already there.CHARTBOOK_STORE, or[store] url = "…"insettings.toml, points it somewhere else: a bigger disk, or a drive a team shares. Not one store per project, because ten projects that pin the same upstream should hold one copy.Layout: one directory per asset, named by its hash, holding the asset’s files under their own names.
~/.chartbook/store/ objects/sha256/7f/3a9c…/treasury_rates.parquet a one-file asset objects/sha256/b2/09e1…/year=2024/part-0.parquet an asset of many files builds/ kept for step 4
The file names are kept because the path handed to a reader has to still end in
.parquet, and a partitioned dataset has to keep folders likeyear=2024/.How the store links to a file. Decided: all four kinds, in the designed order. Reflink, then hardlink, then symlink, then copy (Design: the asset graph, “Links, not copies”). The builder of 3a asked to drop the two middle ones, and the maintainer kept them: they are the cheapest on every disk.
What that means, so that nobody is surprised later. A reflink or a copy is the store’s own bytes: nothing outside the store can change it. A hardlink is the upstream’s file under a second name, and a symlink only points at it. If the upstream pipeline rewrites
_data/rates.parquetin place, an object stored either of those ways changes with it, and still sits under its old hash.chartbook store verifyis what catches that, by hashing every object again. So the step’s “Done when” line, “editing the upstream’s data does not change what the downstream reads”, holds on a disk with reflinks (APFS, btrfs, XFS) and wherever the upstream replaces its files instead of rewriting them; elsewhere it holds only until the upstream rebuilds, andverifysays when it has stopped holding.Decided later the same day, with 3d: a pin asks for a reflink, then a copy, and nothing else. The store still offers all four kinds. So the paragraph above now describes what a build cache would get, and a pin is frozen on every disk (“3d, as built”).
The lockfile’s shape. Decided. JSON, in the index’s own format: the same envelope (
"schema": "chartbook/lock/v1") and arecordslist. It holds this pipeline’sPipelinerecord with only itsidanddependencies(each now with apinned_hash), and, for each pinned asset, the upstream’s own record for it, cut down toid,ref,pipeline,location.path,content_hashandsize_bytes. No timestamp and no absolute path, so pinning twice with nothing changed writes the same bytes and shows no diff.Buildrecords join it in step 4. This is what “no second format” in Design: the index asks for.pinwhen an upstream has not been built. Decided. Pin what is there and say what is not. An asset whose file is missing gets a record with nocontent_hash, exactly as the index writes one today. Asking for its path later fails, and says to build it upstream and runchartbook update. So an upstream with ten dataframes, one of which needs a subscription you do not have, is still usable. The stricter choice, refusing to pin at all, is what--frozenis for in step 4.Hidden files when hashing a directory. Decided. Skip a short, fixed list of files that operating systems create by themselves:
.DS_Store,Thumbs.db,desktop.ini, and any name starting with._. Every other file counts, hidden or not. A Zarr dataset keeps its real metadata in.zarrayand.zattrs, and leaving those out would give two different datasets one hash. No release carries the earlier rule, so this changes it without a new algorithm prefix. It is built with the store (3b), which is keyed by it.A
gitdependency. Decided, and built in 3e. Clone once into~/.chartbook/git/, fetch onupdate, and check the wanted commit out into a directory beside it. From there it is apathdependency. The lockfile records the full commit hash thatrevmeant when it was pinned.What had to be decided first: a fresh clone has the upstream’s code, not its data, because built data is usually git-ignored. So a
gitdependency can only be pinned where the upstream commits its outputs. Otherwise it needs step 4 (run the upstream’sbuild) or a shared store to download from. The maintainer’s answer: build it now, plainly, and have it say so. It does.data.load("pipeline:asset"). Decided, and built in 3d.data.load("treasury_rates")loads this pipeline’s own dataframe,data.load("fed_yield_curve:treasury_rates")loads the pinned copy, and the two-argumentdata.load(pipeline=…, dataframe=…)keeps meaning “through a catalog”. One reference is the form every other call already takes.
Step 4. Running, build records and versions#
Goal: chartbook run builds pipelines in dependency order and skips what is current.
[scripts], withbuildas the reserved name.chartbook run [script].Buildrecords: input hash, output hash, code revision, tools,impure.Skip a pipeline whose input hash is already in the store.
--frozen, for CI.The command rename in Design: the asset graph (“The command surface”):
buildmakes everything current,docsrenders the site.@revresolves. Model versions and labels (slice M4 of Design: models). Settle the open questions there first.Parts:
dataframe#seriesresolves fordata.load(). (Decided against, 2026-10-13: “4e”, below.)
Done when: changing one upstream script and running chartbook run in the downstream
example rebuilds exactly the pipelines that depend on it, and running it again does nothing.
(Added 2026-10-13.) The goal and the “Done when” above are no longer what is wanted. The maintainer decided the model below while the first part was being built, and it replaces them.
The snapshot model#
Decided by the maintainer, 2026-10-13. Two names make it concrete: yield_curve makes data,
and rate_forecast uses it.
Why. Pipelines are worked on side by side, by people and, more and more, by agents working
in parallel, one to a pipeline. Someone is always half-way through a change in yield_curve.
That must not reach rate_forecast. So the store is the one thing every pipeline reads from:
it is read-only, and it changes slowly and on purpose.
The rules.
A pipeline never reads, hashes or builds in a folder it depends on.
rate_forecastdoes not look in../yield_curve/_data/, and nothing it runs is run there.A snapshot is every asset a pipeline declares, as built at one commit, kept in the store and labelled with that commit. Dataframes, charts, notebooks, notes and models alike.
A snapshot is made on purpose, by the pipeline’s owner, and only when everything is committed. It is not made by every build. That is how a pipeline is declared ready for others, and what keeps the label “built by this commit” true.
pinandupdatechoose among snapshots. They do not copy whatever is in the folder.Nothing that happens in
yield_curverebuildsrate_forecast. It rebuilds when it asks to move its pins, withchartbook update.chartbook runbuilds the pipeline it is run in. Agitdependency is the one thing it builds besides, once, in ChartBook’s own clean copy of the commit: nobody works there, so there is nothing to disturb. Data that is pulled from outside is refreshed on purpose, with--force, and not because time has passed.Taking whatever is in a folder stays possible, as an option that is off by default and out of the way, for someone working on both pipelines at once.
What is asked for, and what was got. The split is the one between requirements.txt and
uv.lock:
File |
Says |
Example |
|
|---|---|---|---|
What is asked for |
|
The repository, and a tag or a branch |
|
What was got |
|
The exact commit, and the hash of every file |
|
A commit alone is not enough: the same code gives other data on another day, or with other settings. The lockfile does not try to list every cause. It records the output, the hash of every file, and that catches a difference whatever caused it. What a hash cannot do is bring the bytes back, which is what the store is for, and a store shared between machines later.
Code changes and data changes are told apart by what is asked for. Pinned to a tag or a
commit, the code never changes, and chartbook update brings only newer data built from that
code. Pinned to a branch, update brings newer code as well.
How much of it is built. All seven rules: 5 and 6 in 4a, and 1, 2, 3, 4 and 7 in 4b, both below. What is left is listed there as known gaps: old snapshots are never cleared, a snapshot is not shared between machines, and making one takes its owner’s word that what is on disk was built by the commit.
What it replaces in this step. “chartbook run builds pipelines in dependency order and
skips what is current” is not the goal any more: a pipeline’s dependencies are not built from
it. “Skip a pipeline whose input hash is already in the store” has become “a git dependency is
built once for a commit”. And the Build record has become the snapshot: what one commit of
a pipeline built, with the bytes kept. Proposed “Done when”, in its place: one agent can
rebuild yield_curve as often as it likes while another builds rate_forecast, and nothing
rate_forecast reads changes until it runs chartbook update.
The parts of step 4#
Part |
What |
State |
|---|---|---|
4a |
|
Built |
4b |
Snapshots: an owner declares a build ready, and pinning takes from that and never from the folder |
Built |
4c |
The command rename: |
Built |
4d |
|
Built, for a pipeline’s own models |
4e |
Parts: |
Not to be built. Decided 2026-10-13, below |
4g |
|
Built 2026-10-14, below |
4h |
A version of another pipeline’s model: |
Built 2026-10-14, below |
4i |
A model out of date: what it was trained on has moved |
Built 2026-10-14, below |
--frozen is 4g, below. It followed snapshots, as proposed here: what it has to
check is that the lockfile is complete and that every pin can be had.
Decided by the maintainer, 2026-10-13:
chartbook runnever moves a pin. It pins what has no pin, aschartbook pindoes, and leaves the rest. Design: the asset graph says the opposite: “By default it also refreshespathpins, because the common case is ‘pick up a colleague’s latest.’” The rule from 3c holds forruntoo:chartbook updateis the one thing that moves a pin, so a lockfile changes only in a commit someone meant to make.chartbook runbuilds agitdependency, in a writable copy of its checkout. This is the answer to the question 3e left open: how agitdependency’s built data gets here.chartbook runnever builds in a folder a pipeline depends on (rule 1 above). The first version of 4a did, as Design: the asset graph describes: it ranbuildin every pipeline of the dependency graph, each in its own folder. That was taken out before it was merged. It was also pointless once pins did not move: the other pipeline was rebuilt, and this one went on reading its pinned copy. (2026-10-26:chartbook run --upstream, asked for by the maintainer, builds and snapshots apathdependency that has no snapshot to pin, when the folder is trusted and committed: only when asked, and only where there is nothing to pin.)A build is refreshed on purpose. A git dependency is built once for a commit, and
chartbook run --forcebuilds it again. A pipeline that pulls live data is not rebuilt because a day has passed.The snapshot model, above.
Step 4 is built in parts, 4a first, and the pull requests keep stacking, each branch made from the one before it.
4a, as built#
Added 2026-10-13. A pipeline says how it is built, and one command gets it built.
[scripts]
build = "doit"
test = "pytest"
$ chartbook run
ftsfr/rate_forecast: pinning what it depends on
ftsfr/fed_yield_curve: 3 pinned
Wrote chartbook.lock.
ftsfr/rate_forecast: build
Ran build in 1 pipeline(s).
What |
Where |
|---|---|
Reading |
|
Which pipelines a run is about, and in what order |
|
Running one script |
|
Building a git dependency once, pinning before a build, then running |
|
The command |
|
A writable copy of a git checkout, to build in, and whether it is built |
|
Pinning from that copy when there is one |
|
Letting go of what was built at a commit an update moved away from |
|
Every script in the index |
|
The tests |
|
Not done in 4a, on purpose. There are no snapshots, so pinning still reads a path
dependency’s folder (the model’s rule 1 is met by run and not yet by pin and update).
There is no --frozen. chartbook build still renders the website, so the word still means
two things until 4c. chartbook update still rebuilds nothing.
Proposed: implemented in 4a, and waiting on the maintainer’s yes.
[project] buildand[scripts] buildare one thing. Both are read. A manifest that gives both, with different text, does not load. Pages and the policy keep readingproject.build, which the loader fills from whichever was written.A script’s name matches
[A-Za-z0-9._-]+, the rule an ID component already has, because it is typed afterchartbook run. An empty script does not load.A script is handed whole to
bash -e -c, toshwhere there is nobash, and tocmdon Windows. It runs in the pipeline’s directory, with the environmentchartbook runwas started in, and its output is not captured.bashbecausemodule loadandconda activateexpect it.In a catalog, the script is run in each of the catalog’s pipelines, one that another of them depends on first, and otherwise in the order the catalog lists them. A pipeline the catalog does not list is not run, whoever depends on it. Two of its pipelines that depend on each other are an error that shows the loop, and nothing is run.
The pipeline that was asked for must have the script. A pipeline of a catalog that has no script by that name is passed over and named. A name that no pipeline has is an error.
Only
buildpins, and onlybuildreaches a git dependency.chartbook run testwrites no lockfile and fetches nothing.Each pipeline is pinned just before its own build. A pipeline with no
buildscript is not pinned for.What cannot be pinned stops the run before that pipeline’s build: a dependency that is not there and has nothing pinned, a repository that cannot be fetched, a lost pin. A dependency that has gone and is pinned is no trouble.
The exit code is the failed script’s. One stopped by a signal gives 128 plus the signal’s number, as a shell reports it. What ChartBook printed is flushed before a script starts, so a log has the lines in order.
A git dependency’s working copy is
~/.chartbook/git/work/<host>/<owner>/<name>/<commit>/: the checkout’s files, made writable. It is made once and kept. It is shared by every project of this user that depends on that commit. The checkout itself is never written to.“Built” is a file in the working copy,
.chartbook-built, written when the build exits 0 and removed before a forced one. A build that fails leaves the copy unbuilt, so the next run tries again. One that is built saysbuilt already, at 4f2a91c.What a built git dependency itself depends on is not looked at again, unless
--forceis given. It was needed to build it, and it is built.A git dependency with no
buildscript is not mentioned. That is the ordinary case of a repository that commits its data.pinandupdatepin a git dependency from its working copy when there is one, and from the checkout otherwise.Which commit is built. The lockfile’s, if it names one, since a pin does not move. Otherwise what the
revmeans now.The index lists every script under
scripts, sorted by name. The schema already allowed it.A catalog has no
[scripts]. A manifest that gives one does not load.What it says. One line for each pipeline it runs in, with the directory when that is not where the command was given. Pins are mentioned only when something happened, and a lockfile is never written without a line that says so.
An update that moves a
gitdependency’s commit lets go of what was built at the old one. This changes one case of a rule proposed in 3c (number 7 there: an asset the other pipeline “declares and no longer has on disk keeps its pin”). Example:ref_datais pinned at commit A, withdaily_pricesbuilt there byrun.updatemoves it to commit B, where nothing is built yet. Under the 3c rule the lockfile would then say commit B beside the bytes built from A. Nobody else could get those back: a fresh clone builds B, gets other bytes, and the pin is lost. Andruncould not fix it, becauserunmoves no pin. So the pin is removed,updatesays “let go”, and the nextrunbuilds B and pins it. The rule is unchanged for apathdependency, and for agitone whose commit did not move.
Known gaps in 4a.
Pinning still reads the folder. For a
pathdependency,pinandupdatecopy what is there at that moment, andpinhashes it to say what has changed. Run while its owner is half-way through a rebuild,updatepins the half-built state. Snapshots (4b) close it.Refreshing a git dependency’s data takes two commands:
chartbook run --forcebuilds it again, and the pin does not move untilchartbook update.Nothing clears old working copies. A branch that moves leaves one per commit, each with whatever its build wrote.
chartbook store gcdoes not touch the git cache.Two runs at once in one working copy are not kept apart. Two projects of one user that depend on the same commit share it.
A
pathdependency of agitdependency is not built, and is looked for beside the working copy, where it almost never is. That dependency’s build stops at its pin.On a first run, a
gitdependency is fetched twice: once to choose the commit to build, once by the pin. If the branch moves in between, the pin lands on a commit that was not built, and says “not built”. The next run builds that commit and pins it.
Where the design was wrong, or said less than it seemed to.
“
build… is what the dependency graph invokes”, “chartbook run [script]— execute a named script across the graph”, and “ChartBook’s runner is topological order, a process pool, an input-addressed cache and a per-asset lock.” (Design: the asset graph, “Running things” and “Relationship to orchestrators”.) Decided otherwise: a pipeline’s dependencies are not built from it. The only order left is among a catalog’s own pipelines.“By default it also refreshes
pathpins.” (Design: the asset graph, “Two verbs”.) Decided otherwise.“Today the field is documentation-only … choosing script semantics now means a future
chartbook runcan execute the value as-is with no format change.” (Design: chartbook.toml Format v2.) True of the format and not of the values. Nothing ever ran the field, so nothing ever checked that it held a command. This repository’s ownexamples/yield_curvehadbuild = "module load anaconda3/3.11.4 TeXLive/2023": a note about the environment.chartbook runwould have run it and failed. The example now saysdoit. Other manifests may hold notes too, and the changelog says so.“If variants are ever needed … the natural extension is a
[project.build]table with named keys.” (Design: chartbook.toml Format v2.) Design: the asset graph, written later, has[scripts].[scripts]is what is built.“
chartbook update— move pins forward to current upstream state, and rebuild what that invalidates.” (Design: the asset graph.)updaterebuilds nothing; the pair isupdate, thenrun.“
chartbook runpins anything not yet pinned, so neither verb is needed to get started.” True as built, forbuild. Any other script pins nothing.“
buildstill means ‘render the website’ and keeps doing so untilrunexists; the rename is one breaking change, made once, in the same release asrun.” (Design: the asset graph.)runexists and the rename is 4c. Both are unreleased, so “the same release” still holds, and until 4cchartbook buildand[scripts] buildmean different things.The
dodo.pyin Design: the asset graph that reads another pipeline’s asset at the top of the file failed until the dependency was pinned (“3d, as built”). Underchartbook runit works, because the pin is made beforedoitstarts. Run as plaindoitin a fresh clone, it still fails.The
Buildrecord is one per asset, with arecipe_hash(Design: the asset graph, Design: the index), and “the graph’s nodes are pipelines … ChartBook therefore never needs per-asset build recipes” (Design: the asset graph, “Granularity”). The two do not fit: one build makes all of a pipeline’s assets. The snapshot settles it as one record for a pipeline at a commit, naming each asset’s hash.
Seen while trying it by hand. A pin is read-only (decided in 3d). A build script that
copies a pinned file with cp gets a read-only copy, and cannot write over it on the next
run. Nothing is wrong, and it will surprise someone. Reading the pinned file in place avoids
it.
4b, as built#
Added 2026-10-13. A pipeline’s owner declares a build ready, and that is what others pin.
$ chartbook snapshot # in fed_yield_curve, with everything committed
ftsfr/fed_yield_curve at 4f2a91c: 3 asset(s) in the store
Snapshot made. A pipeline that depends on this one gets it with `chartbook update`.
$ chartbook pin # in rate_forecast
ftsfr/fed_yield_curve: 3 pinned
From its snapshot at 4f2a91c, made 2026-10-13 18:22 UTC.
Wrote chartbook.lock.
What |
Where |
|---|---|
Making a snapshot, and refusing with anything uncommitted |
|
Where a folder is in git |
|
The newest snapshot at or before the commit a folder is on |
|
One shape for an asset’s record, in a snapshot and in a lockfile |
|
Pinning a |
|
A snapshot is never cleared |
|
The commands |
|
The record’s schema |
|
The snapshot’s commit and time, on a dependency in the index |
|
The tests |
|
Decided by the maintainer, 2026-10-13, before it was built: the model above. In his answers: yes to the picture as a whole; yes, everything must be committed before a snapshot; and taking what is in a folder is “only a buried option that is not on by default”.
Not done in 4b, on purpose. chartbook snapshot does not run the build. Old snapshots
are not cleared. Snapshots are not shared between machines. A git dependency is pinned as
before, from ChartBook’s clean copy of the commit, and does not look for a snapshot.
chartbook check still reads a dependency’s manifest from its folder.
Proposed: implemented in 4b, and waiting on the maintainer’s yes.
The command is
chartbook snapshot, run in the pipeline, with no argument.“Everything committed” means the pipeline’s own folder. A changed, staged, deleted or new un-ignored file there is refused, and listed. What git ignores does not count, which is how built data stays out of it. Other work in the same repository does not count either, so one agent’s half-finished change does not hold up another pipeline’s snapshot. The cost: a pipeline that imports code from elsewhere in its repository can be given a label that uncommitted work elsewhere makes untrue.
A snapshot is found by commit and by the pipeline’s folder in its repository, not by the pipeline’s name:
snapshots/<commit>/<folder>/<time>.json. A commit is the same in every clone, so nothing needs to agree on a name.A
pathdependency follows the branch its folder is on.pinandupdatetake the newest snapshot at the commit the folder is on or, if that commit has none, at the nearest commit before it that has one. Its owner can be several commits past the last build they declared ready. A snapshot made on another branch is not found.One commit can have several snapshots, told apart by the time they were made, to the microsecond. The newest is the one a pin moves to. Making one when nothing has changed since the last at that commit makes none.
A dependency is pinned to one snapshot, whole.
pindoes not take one asset from a newer snapshot and leave the rest. This replaces, for apathdependency, 3c’s “pins what has no pin yet: an asset the other pipeline has started to declare”.pinsays what a newer snapshot would change, and stays. It no longer hashes anything to find out: it compares two records.updatedrops what the newer snapshot no longer declares, and lets go of what it did not build. The second is the rule of 4a’s number 19, for the same reason.An asset that is declared and not built is in the snapshot without a hash, and the rest is in it all the same, as
pinalready did. A pipeline that has built nothing has no snapshot to make.The lockfile names the snapshot: its commit in
location.commit, where agitdependency’s commit already is, and its time insnapshot. So the lockfile says which code as well as which data, for both kinds of dependency. The index carries both.With no snapshot, nothing is pinned, and the message says what the owner has to do.
chartbook runstops there, before the build.The record is JSON in the index’s format, like the lockfile, with its own schema.
A snapshot’s files are stored as a pin’s are: a copy-on-write clone or a copy, never a link to the pipeline’s own file.
chartbook store gcnever removes what a snapshot names, whether or not anything pins it yet. A record that cannot be read stops the clearing.The buried option is
--from-folder, onpinandupdate, hidden from--help. It does what both did before 4b. The lockfile then names no commit, and a plainupdateafterwards moves back to a snapshot. Removed 2026-10-17, by decision 4 of Design: reading another pipeline while you work: a lockfile never records a read of a working folder.Looking at a repository takes no lock (
GIT_OPTIONAL_LOCKS=0), so finding a snapshot never gets in the way of whoever is working in that folder.builds/in the store stays a name that is kept free. Records go insnapshots/.
Known gaps in 4b.
A snapshot takes its owner’s word. It records what is on disk as what the commit built. If the owner changed code, committed, and did not rebuild, the label is wrong and nothing notices. (Closed the same day for
chartbook run --snapshot, which builds first: “4b, continued”, below.chartbook snapshotby itself still takes its owner’s word, and the record says which of the two made it.)Nothing clears old snapshots. Each is its own copy of the files where the disk has no copy-on-write clones, and the store grows with each one. (Closed 2026-10-13: “Clearing old snapshots”, below.)
A snapshot is on one machine. A colleague, or CI, has none of them. For a
pathdependency they make their own in that folder. A shared store is the answer, later. (Closed 2026-10-14: “4g, as built”, below.)A lockfile from one machine cannot be put back on another for a
pathdependency unless the same bytes are built there.pinreports a pin whose bytes are not in the store as lost.chartbook checkreads a dependency’s manifest from its folder, as it stands, to see that it declares what a model was trained on. It should read it at the pinned commit.A
gitdependency does not use a snapshot. If its owner made one at that commit on this machine,chartbook runstill builds the commit again in a clean copy.The examples need a clean folder.
cd examples && doitmakes a snapshot ofyield_curve, so it fails, and says why, if that example has been edited and not committed.
Where the design was wrong, or said less than it seemed to.
“Keep the boring files.
pathstays the real location where a parquet file lives, and the store is a cache that links into it.” (Design: the asset graph, “The store”.) For the pipeline’s own folder, still true. For what others read, the store is not a cache: it is the only place they read from.“A cache and a pin are not the same thing … a pinned object is reachable and must never be collected, while an unreferenced build result may be.” (Design: the asset graph.) A snapshot is a third thing: nothing pins it yet, and it must not be collected, because it was declared ready on purpose.
The store’s two indexes: “the build record maps input hash → output hash”. (Design: the asset graph.) What was built maps a commit to output hashes. The commit is the input that people can name and check out; an input hash over code, parameters and tools is not built, and the model does not need one.
“
pinputs back what the store has lost, when the other pipeline still has the same bytes” (3c, proposed 5). For apathdependency it no longer can, because it does not read the folder. A snapshot is what puts bytes in the store.“
pinhashes what is already pinned, to say what has changed upstream” (3c, proposed 6), with its note that a remembered hash would make that cheap. For apathdependency there is nothing to hash any more.
4b, continued: build and snapshot together, and what a snapshot records#
Added 2026-10-13. The maintainer, on whether chartbook snapshot should run the build:
“Running and snapshotting are two separate things that need separate commands. But it’s
clear that you often want to run them together.” And: “a commit is not enough to pin, since
we need to snapshot .env and environment variables and other config, too, right?”
$ chartbook run --snapshot
ftsfr/fed_yield_curve: build
ftsfr/fed_yield_curve at 4f2a91c: 3 asset(s) in the store
Snapshot made. A pipeline that depends on this one gets it with `chartbook update`.
Ran build in 1 pipeline(s).
What |
Where |
|---|---|
Build, then snapshot, with the two checks |
|
The check that everything is committed |
|
What a snapshot was built with, and from |
|
The tests |
|
Decided by the maintainer: they stay two commands. And, asked afterwards with what was built in front of him: one spelling is enough (number 1 below), and named environment variables wait until a real pipeline needs them.
Proposed: implemented, and waiting on the maintainer’s yes.
One spelling for both together:
chartbook run --snapshot. (Decided, 2026-10-13. He had floated two equivalent spellings, that andchartbook snapshot --run. One: it reads in the order it happens,runalready has--forcesorun --force --snapshotis the weekly refresh in one line, and two spellings are two things to document.)It checks that everything is committed before it builds, so nobody is refused after an hour, and again after: a build that changes a committed file gets no snapshot.
The snapshot records that the build ran in the same command,
built_with.run. One made bychartbook snapshotalone saysfalse.A snapshot that follows a build replaces one of the same bytes that took its owner’s word. Otherwise the same bytes make no new snapshot.
A snapshot is of what
buildmakes.chartbook run test --snapshotis refused.In a catalog, each pipeline is built and declared ready in turn, so one later in the catalog pins what an earlier one has just declared ready. A git dependency that is built on the way gets no snapshot.
inputs: a snapshot says what it was built from. The dependencies of the pipeline’s own lockfile, each with its commit, snapshot time andpinned_hash. This is the design’s “hash each input’s hash”, at the granularity of a pipeline: a snapshot ofrate_forecastnames the snapshot offed_yield_curveit read.built_with: a note of the conditions. The ChartBook, Python and platform versions. It explains a difference between two snapshots. No hash covers it, and it does not make a build repeatable.Nothing of a pipeline’s
.env, or of the environment, is recorded. It holds secrets, and a store is something to share. A setting that changes results belongs in a committed file, where the commit covers it.
Not built, by his decision: later. Recording the values of environment variables that a
pipeline names in its manifest as affecting its results, for example
[snapshot] env = ["START_DATE"]. Only named ones, so no secret gets in by accident. A
setting that changes results belongs in a committed file until a real pipeline needs more.
Where the design said less than it seemed to.
The
Buildrecord’stoolsandimpure(Design: the index) are whatbuilt_withis a first, smaller form of.impureis not recorded: nothing declares it.“Make the hashes transitive … hash each input’s hash, not its path or name.” (Design: the asset graph.)
inputsrecords each input’s hash. No hash is yet taken over the inputs, so the chain is recorded and not yet checkable in one step.
4d, as built#
Added 2026-10-13. A model has versions, and a name can point at one. It is listed after 4c in the table above and was built after it; it is placed here, beside the snapshots it is made of.
$ chartbook run --snapshot
ftsfr/rate_forecast: build
ftsfr/rate_forecast at 4f2a91c: 1 asset(s) in the store
model/rate_forecaster: new version 7f3a9c2e41b0
[models.rate_forecaster.aliases]
champion = "7f3a9c2e41b0"
What |
Where |
|---|---|
Every snapshot of a pipeline, on any branch; the versions of one asset; a version’s name |
|
A model’s scores kept in its snapshot record |
|
|
|
|
|
|
|
Versions and labels on the |
|
A label whose version is not here |
|
The tests |
|
Decided by the maintainer, 2026-10-13, before it was built:
A version is made by a snapshot, the first that holds new bytes for the model. Design: models proposed every build that changes the model. Tuning a model all afternoon makes no versions, and every version has a commit that describes it.
A version is named by the start of its content hash, with labels as the names people use. Design: models proposed
v1,v2,v3, and listed their trouble as an open question: two branches that each retrain both make av4.
Not done in 4d, on purpose. A version of another pipeline’s model
(other:model/x@champion) is refused: the lockfile pins one snapshot of a dependency, and a
second version of one of its models beside it has no place there yet. (Built 2026-10-14 as 4h,
below: a label gets a record of its own in the lockfile.) Only models have
version names; a dataframe has snapshots and no names for them. Nothing says a model is out
of date when what it was trained on has moved.
Proposed: implemented in 4d, and waiting on the maintainer’s yes.
A name is twelve digits of the hash, and more only when two versions of the same model would share them. A longer start, the whole hash, and
sha256:in front all name the same version; so does a shorter start of four or more, when typed, if it names only one. (The length was decided by the maintainer, 2026-10-13: twelve, not git’s seven, as Docker and the Linux kernel use. The whole hash is what is stored. A start that matches two versions is refused.)A version is looked for in every snapshot of the pipeline, on any branch of its repository. One made on a branch that was never merged is still a version.
A label is looked up before a hash, and a label may not look like the start of one (four or more hex digits), so the two cannot be confused.
A label points at the start of a hash, at least twelve digits.
chartbook checkwarns when the version is not on this machine, and does not fail: it may be on another. A label that matches two versions is a problem.A version keeps the model’s scores, read from its metrics file when the snapshot is made, so two versions can be compared after the file has been written over. It also has the snapshot’s commit and
inputs.@resolves for a pipeline’s own models and nothing else. The plain name stays the file in the folder: local references are live, and a version is the one way to ask for something that is not.A snapshot says when it makes a version:
model/x: new version 7f3a9c2e41b0.The index lists a model’s versions, oldest first, and its labels. The page lists them newest first. Both are the versions in the store of the machine that built them.
chartbook.models.versions()is the one new function. There is no command that lists versions; the page, the index and the snapshot’s own line are where they are seen.
Known gaps in 4d.
A version is on one machine. It is in the store that holds its snapshot. A label is in the manifest, which is committed, so a label can name a version a colleague does not have. A store to share closes it.
The index and the page depend on the machine that built them. Two machines build different
versionslists from the same commit.No version of another pipeline’s model, no version names for other kinds, no staleness. As above. (The first closed 2026-10-14: “4h, as built”.)
Nothing clears old versions. They are in snapshots, and nothing clears those. (Closed 2026-10-13: a version a label points at is kept by
store gc; one no label names goes with its snapshot. “Clearing old snapshots”, below.)
Where the design was wrong, or said less than it seemed to.
“Recorded in the pipeline’s committed
chartbook.lockso the numbers are the same on every machine.” (Design: models.) A lockfile is what a pipeline depends on; its own models’ versions do not belong in it, and nothing was ever written there. With names worked out from the bytes, nothing needs recording for them to agree.“A team that wants to share model versions points at a shared store.” (Design: models.) Still the answer, and still not built. Until it is, “serving code asks for
model/x@championand gets a path” works on the machine that made the snapshot.“
@revresolves” in this step’s list read as one thing. It is at least three: a version of this pipeline’s model (built), a version of another pipeline’s (not built), and a revision of a dataframe or a chart (nothing has asked for one).
4c, as built#
Added 2026-10-13. Each word means the plain thing.
Command |
Builds the data |
Renders the site |
|---|---|---|
|
Yes |
No |
|
No |
Yes |
|
Yes |
Yes |
What |
Where |
|---|---|
|
|
|
|
A script is told which pipeline and script it is |
|
The tests |
|
Decided by the maintainer, 2026-10-13: make the rename now, as designed, and breaking. “I don’t care about backwards compatibility. Let’s do the right thing. We can add warning maybe. But that’s as far as I’d go.”
Proposed: implemented in 4c, and waiting on the maintainer’s yes.
chartbook buildtakes exactly the arguments ofchartbook docs. It does not takechartbook run’s--force. To rebuild a git dependency and then render:chartbook run --force, thenchartbook docs.With no
buildscript,chartbook buildsays so and renders the site. A project that only documents files it already has is not an error.If the script fails, the site is not rendered, and the exit code is the script’s.
In a catalog, the script is run in each of the catalog’s pipelines, and then the catalog’s site is rendered.
The one protection, and the only compatibility. A script is told which pipeline and script it is, in two environment variables (
CHARTBOOK_RUN_DIR,CHARTBOOK_RUN_SCRIPT).chartbook buildcalled from inside the build script it is running renders the site and prints a warning that says to usechartbook docs.chartbook runcalled from inside the script it is running is refused. Without this, an old build script would run itself without end.chartbook catalog buildis unchanged. It renders the global catalog’s site and runs no script.
Known gaps in 4c.
The project template still says
chartbook build. It is in another repository (cookiecutter_chartbook), which this work does not touch. Itsdodo.pyneeds the one-line change before this is released, or every new project starts with the warning. (Closed the same day another way: the template was moved into this repository, and sayschartbook docs. The old repository is left as it is, for the released versions that render it. “The project template, moved in”, below.)An old
dodo.pystarted with plaindoitruns its own build a second time, nested, before the warning stops it, and may trip over doit’s own lock on its database. The fix is the one-line change.A build script that renders the site itself, as the examples’ do, has it rendered twice by
chartbook build. Taking that step out of a build script means plaindoitno longer makes the site.chartbook catalog buildnow disagrees withchartbook buildabout what “build” means. What it should be called is not settled.
Where the design said less than it seemed to.
“The rename is one breaking change, made once.” (Design: the asset graph.) It did not say that the build script of every existing pipeline calls
chartbook build, so that under its new meaning the command runs the script that called it.“
chartbook build— make everything current: data, then site.” Most build scripts already render the site as their last step. So “data, then site” is, for now, “data and site, then site”.
Clearing old snapshots#
Added 2026-10-13. Built after step 4, on the maintainer’s instruction to go on building and
decide the order myself; this was the first known gap worth closing, because a nightly
chartbook run --snapshot on a disk without copy-on-write clones fills it.
$ chartbook store gc --dry-run
Kept: 4 snapshot(s), naming 9 object(s): 1 because a lockfile pins it, 2 because it is the newest of its pipeline, 1 because it was made in the last 30 days
Would remove 6 snapshot(s):
ftsfr/fed_yield_curve at 2a9c01d, made 2026-08-02 06:00 UTC: nothing pins it, it is not the newest, and it is older than 30 days
What |
Where |
|---|---|
Which snapshots are kept, and why |
|
The folder a snapshot was made from, noted beside the records |
|
Labels read from that folder’s manifest |
|
Removing a record, then what only it named |
|
The command |
|
The tests |
|
Proposed: implemented, and waiting on the maintainer’s yes. All of it; the maintainer asked not to be consulted on rules of this size.
A snapshot is kept if a lockfile pins it, if it is the newest of its pipeline or the newest a branch or tag can pin from, if a label points at a model version in it, or if it was made in the last 30 days. Otherwise it may be removed. The rules are tried in that order, and the first that holds is the reason the command gives. (The branch rule was added by the third review, below:
pinchooses by commit ancestry, so a release branch’s last snapshot is what its users pin, and the plain “newest” would have removed it.)Thirty days. Long enough to come back from a holiday to; short enough that a nightly build does not fill a disk.
--keep-daysmoves it, down to zero.“The newest of its pipeline” is by pipeline ID and folder within its repository, which is what the store can tell without a repository to hand. A pipeline that renames itself becomes two pipelines to this rule for as long as both have snapshots.
The store notes where a snapshot was made from, in a file
originsbeside the pipeline’s records at that commit: one repository folder per line. It is not a record, has no.json, and is not shared. It is howgcfinds the manifest whose labels say which versions to keep.Labels are read from that manifest as it is now, committed or not. A label is what someone is relying on now. A folder that is gone, or a manifest that cannot be read, says nothing, and the other three rules still hold. (Since 2026-10-14 the newest snapshot’s own record of its labels counts too, for a folder on another machine: “4g, as built”, below.)
A lockfile that cannot be found keeps any snapshot that holds an object it named. That is as much as is known about it.
Removing a snapshot removes its record; its objects go by the existing rule, in the same run, when nothing else names them. An object stored in the last hour is spared, as before, so the run that removes a record may leave its objects for the next.
The last record at a commit takes its folder and its
originsnote with it.Nothing is removed if any record cannot be read, as for a lockfile.
The plan is made again when the yes comes, and only what both plans say may go is removed. A
pinthat landed while someone was deciding is honoured. (Third review.)A label shorter than a version’s name keeps nothing.
champion = ""would otherwise match every version. Only what[models.x.aliases]accepts, twelve or more hex digits, is read. (Third review.)The last record at a commit leaves a half-written one alone. Only the
originsnote goes with it, and the folder if it is then empty. (Third review.)
Known gaps.
A label in a manifest that was never a snapshot’s origin is not seen. A clone that pins nothing and makes no snapshots can label a version that
gcon this machine then removes.chartbook checkthere says the version is not on this machine.Git working copies are still never cleared.
~/.chartbook/git/work/grows by one copy per commit built.
Where the design said less than it seemed to. “A recorded version is reachable, so
chartbook store gc never collects it.” (Design: models.) Every version would then be kept
for ever, and Design: models itself lists retention as an open question (“a model retrained
nightly makes 365 a year … probably a per-model keep = N”). As built, a labelled version is
kept and an unlabelled one goes with its snapshot after 30 days, which answers the question
without a new field.
What two adversarial reviews found, and what was done#
Added 2026-10-13. After the step 4 pull requests were merged, two sub-agents reviewed
main with no knowledge of the builder’s conclusions: one for correctness and races, one for
safety. They found twenty-six things between them. Each is in the changelog under “Fixed”, and
the ones that change a rule are here.
Changed rules, all proposed.
A pipeline that is only read gives up nothing but its own files. A git dependency’s manifest, or a pipeline being snapshotted, may declare an absolute path, a
.., or a symlink that reaches outside; every declared path is followed to where it really is (chartbook.hashing.escapes), and one that reaches out isoutside, not pinned, and a problem. This is the boundary 3e implied and did not enforce:pinis safe,runis trust. Apathdependency pinned--from-folderis not checked: it is a folder on this disk that the user chose.A snapshot is pinned whole or not at all. An update to a snapshot with an object the store has lost moves nothing. 4a’s rule 19 and 4b’s rule 8 said a lockfile must never name one commit beside another’s bytes; this was the case that could still do it.
The lockfile ChartBook writes does not count as an uncommitted change for
run --snapshot, before or after the build. The snapshot’sinputsrecord what it says. A build that makes a commit gets no snapshot.The recursion guard sees the whole chain of scripts, in
CHARTBOOK_RUN_STACK, and comes before the pin.A version is found at any commit the repository still has, asked of git’s object store, so an amended-away commit keeps its versions until git prunes it.
Names in the store carry a short hash when they would otherwise collide: a clone’s folder with a port or a cleaned character, a snapshot folder with
--or_top.
The third review read the two commits of this pull request before they were merged, and
found twelve more. The two that mattered: the gc plan was made before the “yes” and run
after, so a pin landing in between lost its only bytes (now the plan is made again on the
yes); and the lockfile exemption for run --snapshot read git status as a terminal would,
so a folder with a space or an accent in its name was refused all the same (now -z). Also:
the recursion guard’s stack is JSON, so a : or a tab in a folder’s name does not break it;
a release branch’s last snapshot is kept; a too-short label keeps nothing; a path that
reaches out is refused before the build; remove() leaves another process’s half-written
record alone; pin --from-folder says relocated; an origins line that is not an absolute
folder says nothing.
Found and left, with the reason.
Two
chartbook snapshotat once make two identical snapshots. Cosmetic; the newer wins.A snapshot that takes over an hour to store can lose an early object to a concurrent
gc. Improbable; a store-wide lock is the fix, and is not built.A shallow clone cannot see snapshots beyond its depth, and the message blames the owner.
chartbook initrenders whatever is onmain, not the installed version; needs tags.No timeouts on git calls. Prompts are off; a GUI credential helper could still block.
A lockfile that cannot be found keeps the snapshots its note recorded, for as long as it is missing (since 2026-10-17; a note from before keeps the newest snapshot holding each of its objects). The command points at
--forget-missing.Store names made before the hashing change are not found again. Nothing is released.
4e: loading one column. Decided against#
Added 2026-10-13. This step’s list ends with “Parts: dataframe#series resolves for
data.load().” Asked what data.load("treasury_rates#dgs10") should hand back, the
maintainer answered a larger question:
“I don’t think I want to develop a domain-specific language. I don’t want to recommend that people load data this way. At this point, they write an SQL an chartbook passes through, or access tha path to the parquet and pass over to polars. I don’t want chartbook’s scope to get too large here.”
Decided.
A reference never selects data. Not a column, not a date range, not rows. ChartBook says where a dataframe is, what it is and which copy you get.
There are two ways to read one: SQL that ChartBook passes through (Design: data access), or
data.path()handed to polars or to anything else.data.load()of a whole dataframe stays, as the short form of the second.A series address stays what it was first for: a name to hang documentation, units, a frequency and an index record on.
[dataframes.x.series.y]and theSeriesrecords are unchanged.
What was built for it. Only the message. data.load("treasury_rates#dgs10"),
data.path(…) and chartbook path … still refuse a #. They said “reserved syntax and not
yet supported”, which promised something that is not coming. They now say what the address is
for and what to do instead. chartbook/resolve.py, parse_declared_ref(resolving=True).
Proposed, and waiting on the maintainer’s yes.
A column named in a manifest still says “not yet”.
trained_on = ["treasury_rates#dgs10"]would say which column a model was trained on. That is a statement about lineage, loads nothing, and Design: the index has a chart’sinputsname a series. It stays reserved, with its old message.A file inside a model,
model/x#weights.pt, is not built either (slice M2 of Design: models), by the same reasoning: it ismodels.path("x") / "weights.pt", and the message now says so. This one is the builder’s reading of the decision, not the maintainer’s words.
Where the design said something else. “The reference grammar has an optional segment for
this … ftsfr/fed_yield_curve:treasury_rates#dgs10” (Design: the asset graph) and the table in
Design: references that has treasury_rates#dgs10 mean “the series dgs10 of that
dataframe”. Both stand, as addresses. What does not stand is this step’s bullet, and “M2.
Parts” in Design: models as something to resolve.
The project template, moved in#
Added 2026-10-13. Decided by the maintainer the same day: “Should we move the project template to this repository? Maybe, right?” and then yes.
chartbook init renders template/, a folder of this repository. It rendered a separate
repository, cookiecutter_chartbook.
What |
Where |
|---|---|
The template |
|
Where |
|
The template’s own tests, and a build of a fresh project |
|
Why. The rename of 4c showed it: the template called a command that had changed its
meaning, in a repository this work could not reach, and no test here could have noticed,
because the tests of chartbook init stand in for the download. Now a command and the
template that uses it change in one pull request, and a test renders a project and builds it
with the ChartBook of the same commit.
Proposed: implemented, and waiting on the maintainer’s yes.
It is a folder at the top of the repository,
template/, not inside the package. It is fetched from git by cruft, as before, so it does not need to be installed. (Changed 2026-10-17, asked by the maintainer:chartbook initrenders the template that came with the installed ChartBook. The folder stays at the top of the repository, and the wheel and the sdist carry it, aschartbook/templatein the wheel.)A project is still made with cruft, and its
.cruft.jsonrecords this repository and thetemplatefolder, socruft updategoes on working for new projects. The template’s own README calls that one of its main benefits. (Changed 2026-10-17: it is rendered with cookiecutter, since cruft clones its template;.cruft.jsonis written as cruft writes it, with the commit the ChartBook was built from, which a build hook records in the package.)The template was copied, not merged in with its history. It came from
cookiecutter_chartbookat commita67eeea, and that is written here and in the test file. The old repository has the history.Four changes on the way in, each needed by something built in this step: the build file renders with
chartbook docs -f; the manifest says[scripts] build = "doit"where it had a note about which modules to load; its copy of the skill is the package’s, kept so by a test; its pixi file does not ask for the removed[all]extra. Two stale lines that told people to build the site withjupyter-booknow saychartbook docs -f.Not brought over: the generated
example/project, which was a rendered copy kept for reference and has to be regenerated on every change (the test that builds a fresh project does that job here); the template repository’s own build file and CI workflow, whose jobs the tests here do; and its editor rules.The old repository is left exactly as it is. Released versions of ChartBook render it, and there
chartbook buildstill means “render the site”. Changing it would break them. Nothing in this work touches it.The skill now has three copies kept the same by hand and checked by a test: the package’s, which is the source;
.claude/skills/chartbook/; and the template’s.
Known gaps.
A project gets the template as it is on
maintoday, whatever ChartBook is installed. That was true before the move and still is. Someone with an older ChartBook who runschartbook initafter a later change to the template can get a build file that uses a command they do not have. Pinning whatchartbook initfetches to the installed version needs releases to be tagged in git, which they are not. Proposed: tag them, and havechartbook initask cruft for that tag.chartbook initstill needs the network.Existing projects point at the old repository.
cruft updatein them keeps reading it until they are re-linked:cruft link https://github.com/backofficedev/chartbook --directory template.A project straight from the template fails
chartbook checkon one thing: it has norepo_url, because it has no git remote yet.chartbook init --githubgives it one. This was so before the move; the new test is what showed it.The template asks for
chartbook>=0.2.0, the version of the ChartBook it comes with (a test keeps_chartbook_versionequal to it): a project made from it needs the release that haschartbook docs, and 0.2.0 is it.
4h. A version of another pipeline’s model — as built#
Added 2026-10-14. The fifth row of the table in Design: models: “other:model/x@champion: the
label is looked up when pinning and frozen in the lockfile, so a build is repeatable;
chartbook update moves it.” Built as that row says, on what 4g left in place.
What |
Where |
|---|---|
A snapshot records its labels by name: |
|
A label becomes a lockfile record of its own, |
|
|
|
The tests |
|
Built as designed. The label is read when pinning, from the snapshot, never from the other
pipeline’s folder; it is frozen in the lockfile; update moves it. --frozen and store gc
need nothing new: a label’s record has a hash like any other, so verify() checks it and the
lockfile’s root note keeps it.
Proposed: decided here.
A label is a record, not a field on the dependency entry. It is one more asset the lockfile pins,
key:model/x@champion, with the version’s full hash and alabelfield. Everything that reads records (resolution,verify, roots,pinned_hash) then works unchanged, and a diff of the lockfile shows a label moving as it shows data moving.An exact version,
other:model/x@7f3a9c2e41b0, is not pinned. The hash is the identity. If a label of this lockfile pins that version, it is at hand whatever became of the snapshot that made it; else it is looked for among the dependency’s snapshots in the store, by the name the dependency gives itself, which the pinned snapshot says, and it may be cleared bygcif no label and no lockfile names it.A label whose version the store cannot supply is pinned without bytes, with the reason in the record (
missing) and in every message, reported asnot built, and a problem under--frozen.pingoes on with the rest.pinreports a label the owner has moved since asstale, like changed data, and does not move it;updatedoes, asmoved; a label taken away isdroppedonupdate.Snapshot.labelschanged shape, from a list of version starts to a map of label to start, since the names are what a dependant pins by. 4g was unreleased; no compatibility.A
gitdependency’s labels are not pinned. It is pinned from a checkout, not a snapshot, and has no versions in the store. A label of one is reported as not a label when it was pinned.
What an adversarial review found before merge (2026-10-14; eleven findings, each fixed
with a test unless said otherwise): an exact version of a dependency declared with a scope
raised a bare ValueError (the local name was built with the scope left on); a label name the
owner’s manifest spells illegally ("my label", or one that reads as a hash) was recorded by
snapshot and crashed every dependant’s pin with no lockfile written (a snapshot records
only names a reference can carry); a label pinned without bytes resolved with the message for
an unbuilt asset, and the reason given was false in the two cases that produce it (the reason
is worked out and carried in the record); update --from-folder dropped label pins as “no
longer declared” (a folder or repository pin carries the labels it has); a pipeline that
renamed its id between snapshots gets no label resolved, and the message now says under which
id and folder it looked (no test; the gap stands); after --from-folder the exact-version
message blamed a missing snapshot, and after gc cleared the snapshot that made a labelled
version, the exact hash failed although the lockfile’s root kept the bytes (a version a label
pins is at hand first); a label without bytes was silent on later pins; verify()’s remedy
for a lost labelled version named the wrong snapshot; the lock schema did not describe label
records and pinned_hash now moves with a label; pin reads every snapshot record once per
labelled model (not fixed: a known gap, below); the gc test held for the wrong reason (sharpened).
A second review, of those fixes, found eight more, all fixed with a test: three about git
dependencies, which have no versions (the exact-version message gave them the --from-folder
remedy, the label message promised that update would pin one, and a dependency that went from
path to git carried its label pins for ever with no event; they are let go now, and both
messages say a git dependency has no versions); a byteless label was reported only on one of
the three paths a pin can take (all three now); chartbook snapshot skipped an illegal label
silently where run --snapshot refuses the manifest (the report lists what it did not record);
the hex pattern for “reads as a version” was not the one check uses (it is); the remedy for a
label whose start two versions share said to make a snapshot (give the label more digits); and
the changelog said “all fixed” of a list with a gap in it.
Known gaps. A git dependency has no versions. Labels are looked up by the id and folder
the pipeline gave itself in the pinned snapshot; a pipeline that renamed its id between the
snapshot that made a version and the one that labels it gets the label pinned without bytes,
and the message says where it looked. pin and the exact-version lookup read every snapshot
record in the store (once per labelled model, or per call); fine for a store of hundreds, not
of millions. Staleness, “this model is out of date because what it was trained on moved”, was
the one unbuilt piece of Design: models; it is 4i, below.
4i. A model out of date — as built#
Added 2026-10-14. The last line of the loop in Design: models: “When upstream data changes, the model’s inputs no longer match, so it shows as out of date.” The last unbuilt piece of that design.
What |
Where |
|---|---|
A snapshot records what each model was trained on: |
|
What the model file is now, against that |
|
The index: |
|
The page: “Out of date”, with each input then and now |
|
|
|
The tests |
|
How it is judged. The model file’s bytes are a version if some snapshot of the pipeline
first held them; that snapshot recorded, when it was made, the content hash of each
trained_on input: a local dataframe’s as the same snapshot has it, another pipeline’s as the
folder’s lockfile pinned it then. Those are compared with the inputs now: the local dataframe
as it is in the folder, the other pipeline’s as chartbook.lock pins it now. An input whose
hash differs has moved, and the model is out of date. Where nothing can be said, nothing is:
a model file that is no version yet (just retrained, not snapshotted), or a version whose
snapshot recorded no inputs.
Proposed: decided here.
The record is the snapshot’s.
pinned_inputson the index record says what is pinned now, as 3d decided; what a version was trained with is written by the snapshot that makes the version, beside its metrics, so it is kept with the version and travels with it. It says what the inputs were when the snapshot was made, which is the design’s “build record” in its simplest form: not what one training run read.Keyed as the lockfile keys assets: a local input by its name, another pipeline’s by the dependency’s key, so the two sides of the comparison line up without a resolver.
Out of date is a warning in
check, not a problem. A model may be kept on purpose, and retraining is the pipeline’s build to run.A retrained model that has no snapshot yet says nothing, rather than “up to date”: its bytes are no version, so there is nothing recorded to compare with. The snapshot that follows makes it one.
What an adversarial review found before merge (2026-10-14; ten findings, each fixed with a
test unless said otherwise): the “Warnings, not problems” paragraph of the CLI reference had
landed under the wrong heading, between a MyST target and the heading it names, so three links
pointed at it (moved); a later snapshot of bytes an earlier one already held wrote trained_on
from the inputs then, so the field did not mean “trained with” on that record, and pin
copied it into dependants’ lockfiles keyed in the owner’s names (a version keeps the inputs it
was first made with: the first-holding snapshot’s record is copied forward, and a lockfile
record carries no trained_on); a model retrained to the same bytes on new inputs is reported
out of date for ever and told to retrain (the message says what happens then; the gap stands,
below); a dependency key renamed after the snapshot leaves the old key in trained_with and
nothing is said (gap, below); the warning printed an absolute path and had no field (it is
chartbook.toml: [models.x] trained_on: …); the snapshot reference table lacked metrics and
trained_on (added); Version did not carry trained_on, so past versions did not show which
data made them (it does, and the index’s versions[] has it); two of the three callers of
staleness() swallowed every exception (none does: staleness() turns the expected failures
into None); the index and check hash the model and its inputs again and read every snapshot
a second time per model (not fixed: a known gap).
Known gaps. A model trained on a dataframe in a [sources] table or query has nothing to
compare, since those have no content hash; such an input is left out. The comparison is as
good as the snapshot’s record: a model whose version predates this change has no
trained_on and is never out of date. A version is its bytes: a model retrained to the same
bytes on new inputs is the same version, keeps the inputs it was first made with, and is
reported out of date although retraining changes nothing. A dependency whose key in
[dependencies] is renamed after the version was made has the old key in trained_with and
no match in now, so nothing is said of it. staleness() hashes the model file and its local
inputs again, without the index’s cache, and reads every snapshot of the pipeline once per
model, in the index and in check. A git dependency’s input is compared by its pinned hash
like any other.
Step 5. Data in databases#
Goal: a dataframe can be a table, and data.load() does not change.
This is Design: data access and GitHub issue 9. It depends only on step 1 (it changes the
Dataframe record’s location), so it can be done any time after that, by someone else, in
parallel with steps 2 to 4.
Step 5, as built#
Added 2026-10-13. Built after step 4, as the second of the two ways the maintainer said data should be read: SQL that ChartBook passes through, or a path handed to polars.
[sources.local]
backend = "duckdb"
url = "duckdb:///./_data/yield_curve.duckdb"
[dataframes.yield_slopes]
source = "local"
query = "select Date, SVENY10 - SVENY02 as slope_10y_2y from fed_yield_curve"
What |
Where |
|---|---|
|
|
The two new location kinds |
|
The engines: parquet, and DuckDB |
|
|
|
Where a machine keeps credentials |
|
The index: |
|
The page |
|
|
|
The example |
|
The tests |
|
Built as designed. The manifest shape, the four Python calls, the rule that SQL is never
parsed, the credential order, the typed location, definition_hash in place of a content
hash, read-only connections, DuckDB as the engine and the fallback, and the worked example.
Proposed: where the design was silent or is departed from.
Cross-pipeline references work in
data.sql()anddata.ref()for file dataframes, as the pinned copy. The design put that in a later phase; the resolution chain exists, so it costs nothing. A table or query of another pipeline was not pinned at first (a snapshot holds files); since 2026-10-15 the snapshot writes it out as parquet into the store, so it is pinned and read across pipelines like a file. The gap is closed in that simple form.A query names one source. The design’s mixed-source rule (register each foreign table in DuckDB first) is not built: with DuckDB the only engine, the case is two DuckDB files, and the honest answer is
ATTACH, which the user can do throughdata.connect(). A query that names two sources is refused and says so.duckdbis a dependency ofchartbook, not an extra, by the one-install rule.A
urlwith credentials in it does not load. The design said “no credentials, ever”; the loader enforces it.An unknown backend is refused by name. No plugin seam yet: DuckDB is the second implementation after parquet, and the rule is three.
data.load()of a table or query returns a LazyFrame that DuckDB backs. The engine binds the query at once; collecting the frame runs it, and an error only running can find is raised then with DuckDB’s message. A file is opened afresh for each load; the frame keeps its connection while it lives.The default policy’s
pathrequirement is met by atableor aquery. The rule asks where the dataframe is; a source answers.checkreports a DuckDB file that is not built, and skips that with--skip-outputs, as for any output. It does not connect.The page is read from the engine when the site is built: columns, row count, first five rows. A source that cannot be read gets a page that says so.
What an adversarial review found before merge (2026-10-13; fifteen findings, each fixed with a test unless said otherwise):
The credential chain,
source_url(), was documented everywhere and called nowhere: the manifest’s URL was what opened every source. The loader now resolves each source through it, refuses a machine URL for another engine, and the index records the manifest’s URL only.A query that bound but failed when run (a cast that fails on one value) aborted the whole site build;
describeandcount(*)had both passed. The page now says what happened and the site is built. Such an error carries DuckDB’s message; whether Polars wraps it asComputeErroror lets DuckDB’s exception through depends on the Polars version (CI’s newer Polars did the second), so the page catchesExceptionand the docs promise the message only.One in-memory connection was shared by every
:memory:source and by files-onlydata.sql(), so a table a user made throughconnect()showed up in SQL over files. Files-only SQL now runs in a fresh engine of its own; a:memory:source is one writable connection, by nature, and the docs say so.A query spliced as
(sql)broke on a trailing;or--comment; it is(\nsql\n)now, with the semicolon dropped.A table or query of another pipeline was reported as “had no dataframe named …
updatepins it”, which was false. It now says it is a table in a source and cannot be read across pipelines yet. The catalog’s two-argument path blamed a missingpath; it resolves the table.A query with a newline or a
|broke the manifest table on its page; the SQL is in a fenced block below it now.{'a': 1}and'{x}'indata.sql()were read as references; a doubled brace is a literal.A connection kept for the process read stale data after a rebuild and held the file’s lock. A file is opened afresh for each load; a frame keeps its own connection while it lives, which is the lock’s true scope, and the docs say to drop it before rebuilding.
duckdb>=1.1was below.pl(lazy=True), which arrived in 1.4; the floor is 1.4, and polars 1.20 for the IO-plugin seam it uses. No test: every environment has a newer one.duckdb://host/xbecame a folder,?optionspart of the file’s name, anduser@passed. A DuckDB URL is a file and nothing else; the rest is refused with a reason.A typo in
tableleft the record silently without columns; the index warns.The page’s first and last dates were
Nonefor a table with adate_col; the engine is asked forminandmax.path = ""with asourcepassed as a file; presence is tested, not truthiness.definition_hashleft out the source, so the same table name in two sources hashed alike.Test hygiene: the dtype in the index test is pinned, and the tests no longer reach into a connection cache, since there is none.
Known gaps. A table or query is not pinned and not in a snapshot, so it is not reproducible
across time or pipelines the way a file is. Only DuckDB. One source per query. A DuckDB file
is opened read-only, which a user can get round with their own duckdb.connect; ChartBook
does not police the engine. A column of a DuckDB UNION type makes .pl(lazy=True) panic
inside DuckDB (a BaseException), which nothing here catches.
Step 6. Readers of the index#
Once the index is stable: render the site from it, a Dagster export, an MCP server, a provenance panel on each page, a diff of two indexes. Each is a reader of one file (Design: the index, “What this unblocks”).
Direction, decided by the maintainer 2026-10-14. The four documents stay as they are:
manifest, lockfile, snapshot, index (Vocabulary). The lockfile is never merged
into the index: it is an input to a build and the index is its output, it has no timestamp on
purpose so that a diff of it means “a pin moved”, and it stays committed; the index reports pins
and never makes one. The pair that converges is snapshot and index: a snapshot is one pipeline’s
index at one commit, kept with the bytes; a site’s index is its own pipeline at the current build
plus each dependency’s pinned snapshot plus the catalog; a site is a pure function of an index
and the write-ups at the commit the index names, so a site can later be built for any snapshot
and lineage can link across sites by commit. That is a direction, not work for now. Superseded
2026-10-16 by Design: the versioned catalog: the two do not converge; a snapshot holds the index of
its version, and a catalog will be assembled from such indexes. The index
records facts about the build, including comparisons it made, and no renderings; each asset
record is to gain its write-up’s path and hash; stale_inputs stays. A writer per site tool
turns the index into pages; the Sphinx writer comes first, after a vocabulary page and an
inventory of what every page shows today; MkDocs and Next.js writers are the roadmap’s reason to
keep the index clean. The inventory of what every page shows, and what the index must gain for a
writer to show it, is Design: the writer, and what every page shows; the five facts it found missing were added the same day
(6c): each write-up’s content_hash, modified_at on file locations, date_range on a dated
dataframe, code_changed_at on the pipeline, and the policy, which the catalog record already
carried.
6a. A diff of two indexes — as built#
Added 2026-10-14. The smallest reader, and the one a reviewer of any build wants:
chartbook index diff BEFORE AFTER says what changed between two builds from their
index.json files alone. chartbook/indexdiff.py; tests/test_index_diff.py.
Proposed: decided here. The two indexes are compared as published (index.published()
applied to both), so a build directory’s full index and the site’s compare as equals and no
local path is ever in the output; generated_at is ignored. Records are matched by kind and
ID. A changed record is described in words a reviewer uses, worked out from which fields
differ: data changed for a content or definition hash, size, row count, columns or files;
pins moved for a pipeline’s dependencies; N new version(s); labels moved; out of date or
up to date again; scores changed; else declared differently with the field names. The
hashes and row counts are printed for data. --json gives every changed field before and
after; --exit-code exits 1 on any difference, as git diff --exit-code does. The index was
judged stable enough for this: the diff reads only kind, id and whole fields, so a field
added to a record later shows as declared differently and breaks nothing.
What an adversarial review found before merge (2026-10-14; eleven findings, each fixed
with a test unless said otherwise): a Plotly chart written without a fixed div_id embeds a
fresh id on every render, so every chart’s content_hash moved on every build and
--exit-code would have fired for nothing (ChartBook’s plotting helpers, the examples and the
template pass a div_id now; a chart’s change reads rendered differently; the docs say what
to do in one’s own code); the reference’s example output could not be produced (a pipeline’s
assets changes with every asset added; the example shows it); declared differently was
said of fields nobody declared (assets, pinned_inputs, trained_with, inputs,
location have words of their own); the inputs out of date were cut at sixty characters
(lists are printed whole); a different root or schema was silent (said first); pins
moved fired for a dependency relocated with no pin moving (it reads the pinned hashes);
a malformed record crashed (refused with a message); dead label parameters on
format_diff() (gone); the model fields read from the local store make two machines’ indexes
differ for no change (not fixed; said in the docs); a test comment was false and an assertion
passed for the wrong reason (fixed; Pipeline wording, a folder without index.json, a
columns-only change and the envelope are tested); versions changed did not tell a version
lost from one replaced (new and gone are counted and named).
Known gaps. A record whose ID changes (a renamed asset) is a removal and an addition, not a
rename. An executed notebook’s HTML carries ids and times from its run, so its hash moves on
every render and the diff says rendered differently of it, as of a chart; the live example
site showed this. A Series record follows its dataframe and is listed on its own. A model’s versions,
trained_with and stale_inputs are the building machine’s. Nothing yet fetches the before
index from git or from a deployed site; the reviewer brings both files.
6b. The provenance panel — as built#
Added 2026-10-14, by the maintainer’s choice of what to build next. Every dataframe, chart and
model page carries a Provenance section, before its manifest sections: what this is and where it is; its content hash,
size, rows and columns, or its definition hash; which pipeline built it and with what script;
what it was made from, at which hash, pinned from which snapshot of which commit, and whether
that has moved since the model was trained; what is made from it; and, for a model, which
version the file is and its labels. chartbook/provenance.py, panel(); the include
provenance.md in both themes; tests/test_provenance.py.
Proposed: decided here.
The pages are readers of the index. The build makes the index first, once, in
build_docs, and hands the records to the page generators; a page’s panel ispanel(record_id, records), which reads the index and nothing else. The same index is written beside the site at the end. An incremental build mixes into each pipeline’s fingerprint a digest of what its panels would say (provenance.digest()), since a panel reads other pipelines’ records: an upstream rebuilt, or a new chart made from this dataframe elsewhere, regenerates every page of each pipeline one of whose panels would change, and no other pipeline’s. This is the first step toward rendering the site from the index, which the maintainer wants to discuss before it is built: a page already carries one section written from its record.Facts, not links. The panel names assets by ID in code, and does not link them; the pages’ existing sections link. A link scheme that works for both themes belongs to the site-from-index design.
The panel is the record’s. Nothing in it is computed from a manifest or a file; what the index does not have (a pipeline’s own commit, a build time) the panel does not say. Those are the
Buildrecords the index design leaves for later.
Decisions by the maintainer, 2026-10-14, on the other readers. An MCP server is an idea for later, filed as GitHub issue 54, not to be built now. Rendering the site from the index is to be discussed very soon and is not to be built before that conversation. A Dagster export is not wanted at all for now.
What an adversarial review found before merge (2026-10-14; six findings, each fixed with a
test, the single index build included, which a test now counts): in an incremental build a reused pipeline’s pages kept panels that
had read other pipelines’ records, so after an upstream rebuild the page disagreed with
index.json (the index is built once, before the plan, and each pipeline’s fingerprint carries a
digest of what its panels say); a build script with a | in it broke the panel’s table (cells
are escaped); an input pinned and also in the site said nothing when the live upstream had
moved on since the pin (it says “now at …”); the docs said a page “ends with” the panel, which
the manifest sections follow (reworded); the index was built twice per build, and what the
hash cache does not cover (parquet footers, DuckDB describes, versions, staleness) ran twice
(once now); the model page, the catalog theme and the incremental path were tested only on
hand-made records (a two-pipeline catalog is built, rebuilt incrementally and checked).
A second review found five small things, fixed: the salts were quadratic in the size of the
index (a reverse map is built once per index, and serves every page too); the whole index was
built before a build directory holding unrelated files was refused (refused first); two
wordings; and the test now counts the index builds and asserts the salt-driven regeneration.
Known gaps. A panel says nothing of a pipeline’s own commit or of when the build ran: the
index has no Build record yet. An input not in the site (another pipeline’s dataframe, when the
site is one pipeline’s) is named and shown at its pinned hash only. The Pipeline page has no
panel; its dependencies’ pins are in the pipeline manifest section already.
Working in the code#
The contributing guide (Contributing) has setup and style. These are the things that are easy to miss:
Run tests with
hatch test, notpytestdirectly.hatch test --allruns six environments (Python 3.10 to 3.13, and Sphinx 7 and 8 on 3.13), and all six must pass.Build the docs with
hatch run docs:build. One warning is expected, about a plotly MIME type ingallery.ipynb. Any other warning is yours.A new module needs a line in
docs_src/reference/api.md, or the docs build warns.The index is a contract. A change to what
chartbook/index.pywrites needs the same change in Design: the index and in the JSON Schema for that record, in one commit. A change to how anything is hashed needs a new algorithm prefix.Every user-visible change needs a changelog entry in
docs_src/changelog.md, in paragraphs that say why. A breaking change is marked breaking and says what to do instead.The agent skill is in two places. Edit
src/chartbook/skills/, then copy to.claude/skills/chartbook/. Updatellm/llms.txtandllm/llms-full.txtwhen commands change.The examples are tests.
cd examples && doitmust build both example pipelines and the catalog, andchartbook checkmust pass on each. They pull live data and need a FRED API key in a git-ignored.env; never print or commit it.Do not edit a design document to match what you happened to build. If the design is wrong, change the design on purpose, say why in the document, and get the maintainer’s agreement. Mark what is decided and what is proposed, as Design: models does.
Commit messages explain why, in paragraphs.
Decisions on record#
Dated, so a later reader can tell what was known when. All were made or approved by the maintainer.
Date |
Decision |
Where it is written |
|---|---|---|
2026-07-20 |
Manifest format v2: one |
|
2026-10-11 |
The asset graph and the index. ChartBook publishes and never orchestrates |
|
2026-10-11 |
Dataframes may live in databases; ChartBook substitutes names and never rewrites SQL |
|
2026-10-12 |
A registry is the first of exactly three ID components, so a dotted scope stays a scope |
Design: references, rule 7 |
2026-10-12 |
|
Changelog, “Earlier in this release” |
2026-10-12 |
Catalog builds are incremental by default, with no running server |
Changelog, “Earlier in this release” |
2026-10-12 |
Scratch files go in |
Changelog, “Earlier in this release” |
2026-10-12 |
The stability policy is one line: experimental, anything can change, breaking changes are in the changelog |
|
2026-10-12 |
Asset names are per kind, not one namespace. Data is the spine |
|
2026-10-12 |
The kind goes in the address ( |
|
2026-10-12 |
|
|
2026-10-12 |
No minor version per step: the asset graph is one change, built under 0.2.0 and wrapped up at the end |
“Versions”, above |
2026-10-12 |
How a directory or glob is hashed; an unchanged index is not rewritten, so |
|
2026-10-12 |
Columns should have SQL-compatible names; a readable name is metadata. A column whose name is not a legal address has no series until a table names it |
|
2026-10-12 |
A model’s training data may be another pipeline’s dataframe, and may be declared now, ahead of dependencies. Making it a real, pinned dependency is the headline case for step 3 |
Design: models, step 3 above |
2026-10-12 |
By default |
|
2026-10-12 |
Models are assets. ChartBook is a model registry, not an experiment tracker. ML operations is a central use case |
|
2026-10-12 |
The store is one per user, at |
“Answers to the open questions”, above |
2026-10-12 |
The store links by reflink, hardlink, symlink, copy, in that order, as designed. |
“Answers to the open questions”, above; Design: the asset graph |
2026-10-12 |
|
“Answers to the open questions”, above |
2026-10-12 |
|
“Answers to the open questions”, above |
2026-10-12 |
Hashing a directory skips |
“Answers to the open questions”, above; Design: the index |
2026-10-12 |
|
“3c, as built”, above |
2026-10-12 |
|
“3c, as built”, above |
2026-10-12 |
A pin is frozen: |
“3d, as built”, above |
2026-10-12 |
|
“3d, as built”, above |
2026-10-12 |
A |
“3d, as built”, above; Design: the index |
2026-10-12 |
|
“3d, as built”, above |
2026-10-12 |
|
“3e, as built”, above |
2026-10-12 |
Clones of |
“3e, as built”, above |
2026-10-13 |
|
Step 4 above; Design: the asset graph, “Two verbs” |
2026-10-13 |
|
Step 4 above, “4a, as built” |
2026-10-13 |
A pipeline never reads, hashes or builds in a folder it depends on. |
Step 4 above, “The snapshot model” |
2026-10-13 |
A snapshot is every asset a pipeline declares, as built at one commit, in the store. Its owner makes it on purpose, and only with everything committed. |
Step 4 above, “The snapshot model” |
2026-10-13 |
Taking whatever is in a folder stays possible only as an option that is off by default and out of the way |
Step 4 above, “The snapshot model” |
2026-10-13 |
The purpose: people and agents work on different pipelines in parallel. The store is the read-only middle they all depend on, updated slowly and on purpose |
Step 4 above, “The snapshot model” |
2026-10-13 |
Step 4 is built in parts, one pull request each, stacked |
Step 4 above |
2026-10-13 |
A version of a model is made by a snapshot: the first that holds new bytes for it. Not by every build |
Step 4 above, “4d, as built”; Design: models |
2026-10-13 |
A version is named by the start of its content hash: twelve digits, with the whole hash stored. The names people use are labels in the manifest, |
Step 4 above, “4d, as built”; Design: models |
2026-10-13 |
Building and making a snapshot together has one spelling, |
Step 4 above, “4b, continued” |
2026-10-13 |
A reference never selects data. No language for loading: SQL passed through, or |
Step 4 above, “4e” |
2026-10-13 |
The project template moves into this repository, as |
Step 4 above, “The project template, moved in” |
2026-10-13 |
The command rename is made now and is breaking: |
Step 4 above, “4c, as built” |
2026-10-13 |
No backwards compatibility in this work: the right design, even if it breaks, with a warning at most. The order of the remaining work is the builder’s to choose |
Step 4 above, “4c, as built” |
2026-10-14 |
Of step 6’s readers: the provenance panel now; an MCP server later (issue 54); the site rendered from the index only after a conversation, very soon; no Dagster export for now |
“6b, as built”, above |
2026-10-14 |
Vocabulary: repository → index → pages → site; a writer per site tool; the four documents stay (manifest asks, lockfile records what was got, snapshot what was made, index what a site shows); the lockfile is never merged into the index; snapshot and index converge as a direction; write-ups are not pinned but their path and hash go in the index; the index records facts and comparisons, no renderings; |
Vocabulary; step 6 above |
2026-10-15 |
Step 6 complete: the Sphinx writer renders every page from the index and the files it names, the build calls it ( |
me |
2026-10-16 |
Cross-pipeline dependencies mean two pipelines on one machine; several machines use a shared store or a database adapter, never a transport feature. A table or query in a DuckDB file is pinned as parquet (#65). The store is a setting of the machine or the organization, never of the pipeline: settings layers env → user → system-wide → default, the store as a URL; backends stay per pipeline; plugins behind extras; a warehouse pins by version. Written up in |
Jeremy, me |
2026-10-16 |
The S3 store is built behind |
me |
2026-10-16 |
Index and snapshot stay two documents sharing one record format; a snapshot holds the index of its version and the files a writer needs, so any version renders from the store alone. A catalog shows every snapshot in the store of every pipeline plus each pipeline’s working version (never “latest”); links follow pins; a version list on each pipeline page. Written up in |
Jeremy, me |
2026-10-25 |
Dependencies between pipelines must form a directed acyclic graph. |
Jeremy, me; Depending on Another Pipeline |
Open questions, collected#
Each is explained where it lives.
For model versions: nothing open. One of another pipeline’s model is 4h, out-of-date models are 4i, versions travel through a store a team shares (4g), retention is answered by “Clearing old snapshots”. How they are named and what makes one is decided. (Design: models; step 4 above, “4d, as built”)
Whether dataframes and charts should still be required to have a write-up, or only strongly recommended, as models are. (Design: models, “Decided while building it”)
Whether a pipeline’s own
[policy]table is wanted. It is honoured today, as a proposed answer to a crash. (Design: chartbook.toml Format v2, “Since this was written”)The things settled while building declared dependencies (twelve), the store (ten) and pinning (fourteen), cross-pipeline references (eleven) and
gitdependencies (twelve), each listed as proposed under its “as built” heading. (Step 3 above)The nineteen things settled while building
chartbook run, and the proposed “Done when” that replaces step 4’s. (Step 4 above, “4a, as built” and “The snapshot model”)The seventeen things settled while building snapshots, and its known gaps. Two were answered the same day:
chartbook run --snapshotbuilds first, and “Clearing old snapshots” says how old ones go. (Step 4 above, “4b, as built”)Whether
chartbook checkshould run--frozen’s check too, so that CI is one command; and whether a lockfile should record which store its pins were made into. (Step 4 above, “4g, as built”)For data in a database: when a second engine is wanted and the backend contract is extracted; whether a table-backed dataframe can be pinned, by hash of a snapshot of its rows or not at all. (Step 5 above)
What
chartbook catalog buildshould be called now thatchartbook buildruns a build script, and whether a build script should still render the site itself. (Step 4 above, “4c, as built”)A shared store, as a second way for a
gitdependency’s built data to get here. The first is built:chartbook runbuilds it. (Step 4 above, “4a, as built”)How a chart says it was made from another pipeline’s data. (Step 3 above)
Whether the site should render from the index, and when. (Step 6)
plotting.line()and its siblings can only use the two built-in backends, although third parties can register others. (Writing a Plotting Backend)The project template’s CI should run
chartbook check --skip-outputs, once 0.2.0 is on PyPI.