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:

  1. The manifest is the contract. chartbook.toml says what exists and where. ChartBook reads it; it never requires your code to call ChartBook.

  2. ChartBook publishes; it never orchestrates. It does not schedule, retry or run your analysis. Your own tool (doit, make, a shell script) does that.

  3. 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.

  1. Scale to zero. No capability may require a running service to be useful to one person on one machine.

  2. A convenience is never a requirement. data.path() is nice. A script that hardcodes the same path, in any language, must keep working.

  3. No orchestration. No scheduler, retries, backfills, run history or run UI. Export the graph to a tool that has them.

  4. No plugin system before a second implementation exists. Leave one internal seam. Write the second implementation. Then extract the interface.

  5. Some things are singular. Hash computation, name resolution and the index schema each have exactly one implementation, on purpose.

  6. No features for machines alone. Anything built for programs must fall out of something that already helps a person.

  7. Refuse, do not guess. Syntax that is reserved for later raises “not yet supported”. It never quietly does something else.

  8. Plain files stay plain. A declared path is 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

Design: chartbook.toml Format v2

The manifest: tables, fields, pipeline IDs, catalogs, policy

Built

3

Design: references

How an asset is addressed

Parser built. Local references resolve live; another pipeline’s resolve pinned

4

Design: the asset graph

Dependencies, pinning, the store, build records, what ChartBook will not do

Step 3 built: declared dependencies, the store, pinning, cross-pipeline references, git dependencies. Step 4 is done: [scripts], chartbook run, snapshots, the command rename, and versions of a model. Its last listed part, loading one column, was decided against. Nothing is next by default: what is open is listed under “Open questions” The model the maintainer decided on 2026-10-13 is in this page, under step 4, and overrides Design: the asset graph where they differ

5

Design: the index

The index file, record by record

First implementation built

6

Design: models

Models as assets

First slice built: declared models

7

Design: data access

Dataframes that live in a database

Built for DuckDB, 2026-10-13: [sources], table, query, data.sql()

8

Design: stores and backends

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

Design: the versioned catalog

Index and snapshot settled; a catalog of every version of every pipeline

Step 1, the self-sufficient snapshot, built 2026-10-16

10

Design: trust, for a build of files on this machine

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: chartbook trust, ~/.chartbook/trust.toml, the gates in docs, build, run and catalog build

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

manifest.py, identity.py

repo_url defaults to the git remote

The documentation site

build_docs.py, sitefiles.py, writers/sphinx.py, docs_src_pipeline/, docs_src_catalog/, chartbook docs

Two stages: write Markdown into _docs/, then run Sphinx. chartbook build runs the build script first

Incremental builds and --watch

incremental.py

A state file of file sizes and times. No server. Catalog builds are incremental by default

chartbook check, the CI gate

check.py

Required fields, broken paths, expiring licenses. --skip-outputs for CI that does not build data

The reference grammar

refs.py

All of it parses

Resolving local references

resolve.py, assets.py, data.py, chartbook path

One resolver, typed locations. Live: the declared path

git dependencies

gitcache.py, lock.py

Cloned into ~/.chartbook/git/, checked out at a commit, then pinned like a directory. Only what the repository commits, until chartbook run builds it in a working copy

Named scripts and chartbook run

manifest.py (read_scripts), run.py, gitcache.py (work_copy)

[scripts], with build reserved. Runs a script in the pipeline it is given. Pins what has no pin before a build; never moves a pin. Never builds in a folder the pipeline depends on. Builds a git dependency once, in a clean copy

Resolving another pipeline’s assets

resolve.py (_resolve_pinned), lock.py, store.py

pipeline:name, for a declared dependency, resolves through chartbook.lock to the pinned copy in the store. Every kind, every front door, and data.load("pipeline:name")

The index, index.json

index.py, hashing.py, schemas/

Written by every build, beside the HTML. One hash rule; hashes cached in the build directory

Series tables

manifest.py (validate_series)

[dataframes.x.series.y] documents a column. In the index; not on pages yet

Models

models.py, manifest.py (validate_models), writers/sphinx.py (model_page())

[models.x], chartbook.models.path(), a page and an index record per model. Versions, made by a snapshot and named by the start of the content hash, with labels in [models.x.aliases]

Declared dependencies

manifest.py (validate_dependencies), resolve.py (locate_pipeline), check.py (check_dependencies)

[dependencies], with path or git. One resolution chain. In the index, and checked

The store

store.py, config.py (get_store_path), chartbook store verify

Objects named by content hash, behind has, get, put, delete. Four kinds of link

Snapshots

snapshot.py, chartbook snapshot

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 materialize() lays a version out from the store alone (Design: the versioned catalog)

Pinning

lock.py, chartbook pin, chartbook update, chartbook store gc

chartbook.lock: the commit, and a content hash for every asset, of every dependency. A folder is pinned from a snapshot, a repository from a clean copy. pin never moves a pin; update does. pin --frozen and run --frozen write no lockfile and no object (only, where the store can be written, this machine’s note for store gc, since 2026-10-17) and say whether every pin can be had

Loading a dataframe through a catalog

data.py

data.load(pipeline=…, dataframe=…). The older, two-argument form. It enters the resolution chain at the catalog and does not read [dependencies]

Scratch directory

data.scratch()

<pipeline>/_data/_scratch/

Freshness lines on pages

writers/sphinx.py, chartbook-freshness.js

“Data updated 2 days ago”, computed in the browser

Data glimpses

create_data_glimpses.py

Reads the manifest, not dodo.py

Scaffolding

scaffold.py, chartbook init --git/--github, template/

Renders the cookiecutter template, which is the template/ folder of this repository

Plotting helpers and backends

plotting/

The one real plugin system. See Writing a Plotting Backend

Reserved for later, and refused

refs.py, resolve.py, identity.py, cli.py

#part, @rev, registry prefixes, chartbook push

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. [dependencies] in the manifest (path first). One resolution chain in one function: an explicit location in [dependencies], then the local catalog, then (reserved) registries, then an error that lists candidates. dependencies on the Pipeline record. check verifies each one resolves

A pipeline says what it depends on, and check says whether that can be found. A cross-pipeline trained_on or chart input must name a declared dependency (a warning first, an error once 3d lands)

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. chartbook store verify

Nothing visible. This is the piece everything after sits on, and it can be tested alone

Nothing uses it yet

3c (built)

Pinning. chartbook.lock, written in the index’s record schemas. chartbook pin snapshots each dependency’s declared assets into the store and records their hashes. chartbook update moves pins forward. chartbook store gc, with the lockfile as its roots

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. assets.path("pipeline:asset"), data.path, chartbook path and data.load("pipeline:asset") resolve through the lockfile into the store, never to the upstream’s working directory. Loading from an undeclared pipeline fails and says what to add. A model’s cross-pipeline trained_on is recorded by pinned hash. A second example pipeline, examples/rate_forecast, whose model is trained on yield_curve’s data

The payoff. The case in the box above works end to end

git dependencies

3e (built)

git dependencies. { git = "…", rev = "…" }: fetch at a revision into a cache, then as for path

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 [dependencies] table, validated when the manifest loads

chartbook/manifest.py, validate_dependencies()

The resolution chain, and the one place a pipeline’s name becomes a location

chartbook/resolve.py: locate_pipeline(), PipelineLocation, known_as()

Matching a name against a set of pipeline IDs, without failing on a miss

chartbook/identity.py, match_pipeline_ref()

dependencies on a Pipeline record, and inputs written by a dependency’s name

chartbook/index.py, schemas/chartbook/v1/Pipeline.json

check: a dependency that is not there; training data it does not declare; one ID for two directories in a catalog

chartbook/check.py: check_dependencies(), check_model_lineage()

The warning for a trained_on that names an undeclared pipeline

chartbook/manifest.py, validate_models()

The tests

tests/test_dependencies.py

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.

  1. A git dependency may be declared now, though nothing fetches it until 3e. It is validated, recorded in the index, and check warns that it was not looked at. (The table above says “path first”. Refusing git until 3e was the other option; the design’s own example manifest would not have loaded.)

  2. rev is optional on a git dependency. With none, the revision is whatever the repository’s default branch has when it is pinned, as a path is whatever the directory holds. What that fetch does is 3e’s to settle.

  3. { rev = "…" } with no git is refused, with a message. Design: chartbook.toml Format v2 shows that form, which needs [resolve] default_host to work out the URL. Not built.

  4. 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.

  5. 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.

  6. A trained_on that names an undeclared pipeline warns when the manifest loads, so build shows it as well as check, and check does not fail on it. It becomes an error in 3d.

  7. check needs a path dependency to be on disk, with --skip-outputs too. A dependency is not something this pipeline builds. A project whose CI checks out one repository will want git dependencies, which is 3e.

  8. Within one build, a pipeline goes by the build’s name for it. If a pipeline calls ../fed_yield_curve yield_curve and the catalog calls that directory ftsfr/fed_yield_curve, the catalog’s index says ftsfr/fed_yield_curve in both dependencies and inputs. Alone, the pipeline’s index says yield_curve.

  9. One ID for two directories is reported by check on a catalog, when a dependency’s key is a catalog pipeline’s ID and its path is 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.

  10. A catalog with a [dependencies] table does not load. It reads from no pipeline.

  11. 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.

  12. 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: has, get, put, delete, and verify

chartbook/store.py, Store

The four kinds of link, and the one place they are tried in order

chartbook/store.py: LINKS, _link(), _reflink()

Where the store is: CHARTBOOK_STORE, then [store] url in the user’s settings.toml, then in the organization’s, then ~/.chartbook/store/

chartbook/config.py, get_store_path()

chartbook store verify

chartbook/cli.py

Which files make up an asset, for hashing and storing alike; the files that never do

chartbook/hashing.py: find_files(), is_ignored()

The tests

tests/test_store.py, tests/test_hashing.py

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.

  1. An object is hashed after it is stored, not before. put links 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.

  2. 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.

  3. put takes the kinds of link to try. The default is the designed four. A caller that must have a frozen object passes links=("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.

  4. 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.

  5. An empty directory is not stored. There is nothing to pin, and its hash would be the hash of an empty file.

  6. The store says which layout it has, in store.json. A store laid out by a later ChartBook is refused, not misread.

  7. A store comes into being when something is first put in it, never by being looked at. chartbook store verify on a machine with no store says “OK (0 object(s))” and creates nothing.

  8. 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.

  9. verify exits 1 if any object has changed, and prints hash: what is wrong. It ignores the same operating-system files that hashing ignores, so Finder looking inside the store does no harm.

  10. 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.

  1. “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 verify is what protects it.

  2. “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 chartbook.lock; pin() and update(); what gc keeps

chartbook/lock.py

The lockfile’s JSON Schema

chartbook/schemas/chartbook/lock/v1.json

Which lockfiles name the store’s objects

chartbook/store.py: add_root(), roots(), remove_root()

Every asset a pipeline declares with a path, from its raw manifest

chartbook/resolve.py, declared_files()

chartbook pin, chartbook update, chartbook store gc

chartbook/cli.py

The tests

tests/test_lock.py

Decided by the maintainer, 2026-10-12, while it was being built:

  1. pin never moves a pin; update does. A second pin pins what has no pin yet and leaves the rest exactly where it is, saying what has changed upstream.

  2. chartbook store gc keeps what registered lockfiles name, and asks before removing anything. Each pin and update notes its lockfile in the store. A lockfile that cannot be found still keeps its pins unless --forget-missing is given. --dry-run only lists; --yes does 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.

  1. The lockfile leaves out generator as well as generated_at. The decision was “no timestamp”. Without the ChartBook version too, upgrading ChartBook does not rewrite every lockfile.

  2. 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.

  3. Every kind of asset is pinned: dataframes, charts, notebooks, notes and models. The plan says “declared assets”, and with links it costs nothing.

  4. A dependency’s pinned_hash is 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.

  5. pin puts 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 and pin exits 1; the pin is not moved to what is there now.

  6. pin hashes 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.

  7. An asset the other pipeline stops declaring keeps its pin under pin, and is dropped by update. One it declares and no longer has on disk keeps its pin under both. (2026-10-13, proposed: one exception, for a git dependency that an update moves to another commit. See “4a, as built”, number 19.)

  8. A pinned dependency whose directory has gone is fine for pin. The pins are in the store, which is the point. For update it is an error.

  9. No lockfile is made for a pipeline with no dependencies. Removing the last one leaves a lockfile with nothing in it.

  10. pin reads only the pipeline’s ID and its [dependencies], so something wrong elsewhere in the manifest does not stop it.

  11. pin says when what it stored is held as links to the other pipeline’s files, and points at chartbook store verify.

  12. gc leaves alone anything stored in the last hour. A pin puts files in the store before it writes the lockfile that names them, so a gc running at the same moment would otherwise remove them.

  13. gc reads 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.

  14. gc removes 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.

  1. “chartbook.lock is … its own Build records and its resolved dependencies” (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.

  2. “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 Pipeline record has only id and dependencies, and the index’s schema for a Pipeline also requires name, location and assets, which would change the lockfile every time the manifest did. The lockfile has an envelope schema of its own that says so.

  3. “chartbook run pins anything not yet pinned, so neither verb is needed to get started.” There is no run until step 4, so until then pin is how to get started.

  4. “chartbook update … and rebuild what that invalidates.” That also needs run. update moves pins and rebuilds nothing.

  5. “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: [dependencies], then chartbook.lock, then the store

chartbook/resolve.py, _resolve_pinned()

The two errors that say what to add or run

chartbook/errors.py: DependencyNotDeclaredError, AssetNotPinnedError

data.load() with one reference

chartbook/data.py

Pins are a clone or a copy; an object that was a link is replaced

chartbook/lock.py (PIN_LINKS), chartbook/store.py (_is_frozen)

pinned_inputs on a Model record; pinned_hash on a dependency

chartbook/index.py, schemas/chartbook/v1/Model.json

An undeclared pipeline in trained_on is an error

chartbook/manifest.py, validate_models()

The second example

examples/rate_forecast

The tests

tests/test_pinned_refs.py, tests/test_integration.py

Decided by the maintainer, 2026-10-12, while it was being built:

  1. A pin is frozen. pin and update ask 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.

  2. data.load() takes one reference, beside the two-argument form, which still means “through a catalog”.

  3. 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. inputs stays a list of IDs.

  4. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. data.load()’s arguments. One argument is a reference. With a dataframe, the first is the pipeline, as it always was, so no existing call changes. A mix is a TypeError that shows both forms.

  7. 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.

  8. pinned_inputs holds 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.

  9. A lockfile that cannot be read does not stop a build. The index is written without what is pinned, with a warning.

  10. 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_ref is gone.

  11. The example retrains when chartbook.lock changes. 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.

  1. The dodo.py in Design: the asset graph, “Paths”: "file_dep": [data.path("fed_yield_curve:treasury_rates")]. It works, once pinned. But a dodo.py is 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 on chartbook.lock instead, which exists as a name before it exists as a file.

  2. “path() … reads the raw chartbook.toml and nothing else … it raises only when the name is not declared or its entry has no path.” 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.

  3. 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.

  4. The handoff brief: “doit forget --all in 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’ _data folders 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

chartbook/gitcache.py, GitCache

Where the cache is: CHARTBOOK_GIT_CACHE, then [git] path, then ~/.chartbook/git/

chartbook/config.py, get_git_cache_path()

Pinning from a checkout, at the locked commit

chartbook/lock.py, _fetch()

check looks at a fetched dependency, offline

chartbook/check.py, _fetched_checkout()

The commit, on a dependency in the index

chartbook/index.py, schemas/chartbook/v1/Pipeline.json

The tests, all on repositories made by the test

tests/test_git_dependencies.py

Decided by the maintainer, 2026-10-12, before it was built:

  1. Build it now, plainly. Clone, check out the revision, then treat it as a path dependency. 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.

  2. 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 own git.

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.

  1. The cache’s layout. repos/<host>/<owner>/<name>.git and checkouts/<host>/<owner>/<name>/<commit>/. A repository on this disk goes under local/. The same repository reached by two URLs shares one clone.

  2. The lockfile records the commit beside the declared rev: "location": { "kind": "git", "url": …, "rev": "v1.2", "commit": "4f2a91c0…" }. rev is what the manifest says; commit is what it meant when it was pinned. With no rev, the repository’s default branch.

  3. pin stays at the locked commit, and uses the network only if this disk has lost its copy of it. update fetches again and asks what the rev means now, so a branch moves and a tag or a commit does not.

  4. A changed git or rev in the manifest is followed by update, not by pin. pin says that the manifest now names something else, and stays.

  5. 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, pin carries on, as it does for a directory that has gone.

  6. 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.

  7. Git is never left waiting for someone to type. Prompts are off, so a repository that needs a password ChartBook cannot supply fails.

  8. A URL or a rev that git could take for an option is refused, when the manifest loads and again before git is run: one that starts with -, a rev with anything but letters, digits, ., _, / and -, and git’s ext:: transport, which runs a command.

  9. A path is a repository too, relative to the manifest: git = "../ref_data".

  10. A checkout is read-only and has no .git in it. It is a copy of one commit, not a place to work.

  11. check never uses the network. It looks at a git dependency where it has been fetched, at the lockfile’s commit, and warns when it has not been.

  12. 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.

  1. “path versus rev: 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.

  2. “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 run or a shared store later.

  3. The build record writes a dependency’s location as one string ("https://gitlab.internal/…@a1b2c3d"), and the Pipeline record as an object ({ "kind": "git", "url": …, "rev": … }). The lockfile and the index use the object, with commit added. 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.

  1. 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 = "…" in settings.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 like year=2024/.

  2. 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.parquet in place, an object stored either of those ways changes with it, and still sits under its old hash. chartbook store verify is 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, and verify says 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”).

  3. The lockfile’s shape. Decided. JSON, in the index’s own format: the same envelope ("schema": "chartbook/lock/v1") and a records list. It holds this pipeline’s Pipeline record with only its id and dependencies (each now with a pinned_hash), and, for each pinned asset, the upstream’s own record for it, cut down to id, ref, pipeline, location.path, content_hash and size_bytes. No timestamp and no absolute path, so pinning twice with nothing changed writes the same bytes and shows no diff. Build records join it in step 4. This is what “no second format” in Design: the index asks for.

  4. pin when 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 no content_hash, exactly as the index writes one today. Asking for its path later fails, and says to build it upstream and run chartbook 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 --frozen is for in step 4.

  5. 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 .zarray and .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.

  6. A git dependency. Decided, and built in 3e. Clone once into ~/.chartbook/git/, fetch on update, and check the wanted commit out into a directory beside it. From there it is a path dependency. The lockfile records the full commit hash that rev meant 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 git dependency can only be pinned where the upstream commits its outputs. Otherwise it needs step 4 (run the upstream’s build) or a shared store to download from. The maintainer’s answer: build it now, plainly, and have it say so. It does.

  7. 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-argument data.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], with build as the reserved name. chartbook run [script].

  • Build records: 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”): build makes everything current, docs renders the site.

  • @rev resolves. Model versions and labels (slice M4 of Design: models). Settle the open questions there first.

  • Parts: dataframe#series resolves for data.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.

  1. A pipeline never reads, hashes or builds in a folder it depends on. rate_forecast does not look in ../yield_curve/_data/, and nothing it runs is run there.

  2. 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.

  3. 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.

  4. pin and update choose among snapshots. They do not copy whatever is in the folder.

  5. Nothing that happens in yield_curve rebuilds rate_forecast. It rebuilds when it asks to move its pins, with chartbook update.

  6. chartbook run builds the pipeline it is run in. A git dependency 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.

  7. 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

chartbook.toml

The repository, and a tag or a branch

rev = "v1.2"

What was got

chartbook.lock

The exact commit, and the hash of every file

4f2a91c…, sha256:7ff1…

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

[scripts] and chartbook run

Built

4b

Snapshots: an owner declares a build ready, and pinning takes from that and never from the folder

Built

4c

The command rename: build makes everything current, docs renders the site. Breaking

Built

4d

@rev resolves. Model versions and labels

Built, for a pipeline’s own models

4e

Parts: dataframe#series for data.load()

Not to be built. Decided 2026-10-13, below

4g

--frozen, and a store a team shares

Built 2026-10-14, below

4h

A version of another pipeline’s model: other:model/x@champion

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:

  1. chartbook run never moves a pin. It pins what has no pin, as chartbook pin does, and leaves the rest. Design: the asset graph says the opposite: “By default it also refreshes path pins, because the common case is ‘pick up a colleague’s latest.’” The rule from 3c holds for run too: chartbook update is the one thing that moves a pin, so a lockfile changes only in a commit someone meant to make.

  2. chartbook run builds a git dependency, in a writable copy of its checkout. This is the answer to the question 3e left open: how a git dependency’s built data gets here.

  3. chartbook run never builds in a folder a pipeline depends on (rule 1 above). The first version of 4a did, as Design: the asset graph describes: it ran build in 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 a path dependency that has no snapshot to pin, when the folder is trusted and committed: only when asked, and only where there is nothing to pin.)

  4. A build is refreshed on purpose. A git dependency is built once for a commit, and chartbook run --force builds it again. A pipeline that pulls live data is not rebuilt because a day has passed.

  5. The snapshot model, above.

  6. 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 [scripts], and [project] build as the same thing

chartbook/manifest.py, read_scripts()

Which pipelines a run is about, and in what order

chartbook/run.py, plan()

Running one script

chartbook/run.py, execute()

Building a git dependency once, pinning before a build, then running

chartbook/run.py, run()

The command

chartbook/cli.py, run_command()

A writable copy of a git checkout, to build in, and whether it is built

chartbook/gitcache.py, GitCache.work_copy(), is_built()

Pinning from that copy when there is one

chartbook/lock.py, _fetch()

Letting go of what was built at a commit an update moved away from

chartbook/lock.py, _pin_asset()

Every script in the index

chartbook/index.py, pipeline_records()

The tests

tests/test_run.py; one end to end in tests/test_integration.py

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.

  1. [project] build and [scripts] build are one thing. Both are read. A manifest that gives both, with different text, does not load. Pages and the policy keep reading project.build, which the loader fills from whichever was written.

  2. A script’s name matches [A-Za-z0-9._-]+, the rule an ID component already has, because it is typed after chartbook run. An empty script does not load.

  3. A script is handed whole to bash -e -c, to sh where there is no bash, and to cmd on Windows. It runs in the pipeline’s directory, with the environment chartbook run was started in, and its output is not captured. bash because module load and conda activate expect it.

  4. 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.

  5. 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.

  6. Only build pins, and only build reaches a git dependency. chartbook run test writes no lockfile and fetches nothing.

  7. Each pipeline is pinned just before its own build. A pipeline with no build script is not pinned for.

  8. 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.

  9. 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.

  10. 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.

  11. “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 says built already, at 4f2a91c.

  12. What a built git dependency itself depends on is not looked at again, unless --force is given. It was needed to build it, and it is built.

  13. A git dependency with no build script is not mentioned. That is the ordinary case of a repository that commits its data.

  14. pin and update pin a git dependency from its working copy when there is one, and from the checkout otherwise.

  15. Which commit is built. The lockfile’s, if it names one, since a pin does not move. Otherwise what the rev means now.

  16. The index lists every script under scripts, sorted by name. The schema already allowed it.

  17. A catalog has no [scripts]. A manifest that gives one does not load.

  18. 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.

  19. An update that moves a git dependency’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_data is pinned at commit A, with daily_prices built there by run. update moves 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. And run could not fix it, because run moves no pin. So the pin is removed, update says “let go”, and the next run builds B and pins it. The rule is unchanged for a path dependency, and for a git one whose commit did not move.

Known gaps in 4a.

  1. Pinning still reads the folder. For a path dependency, pin and update copy what is there at that moment, and pin hashes it to say what has changed. Run while its owner is half-way through a rebuild, update pins the half-built state. Snapshots (4b) close it.

  2. Refreshing a git dependency’s data takes two commands: chartbook run --force builds it again, and the pin does not move until chartbook update.

  3. Nothing clears old working copies. A branch that moves leaves one per commit, each with whatever its build wrote. chartbook store gc does not touch the git cache.

  4. 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.

  5. A path dependency of a git dependency is not built, and is looked for beside the working copy, where it almost never is. That dependency’s build stops at its pin.

  6. On a first run, a git dependency 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.

  1. “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.

  2. “By default it also refreshes path pins.” (Design: the asset graph, “Two verbs”.) Decided otherwise.

  3. “Today the field is documentation-only … choosing script semantics now means a future chartbook run can 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 own examples/yield_curve had build = "module load anaconda3/3.11.4 TeXLive/2023": a note about the environment. chartbook run would have run it and failed. The example now says doit. Other manifests may hold notes too, and the changelog says so.

  4. “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.

  5. “chartbook update — move pins forward to current upstream state, and rebuild what that invalidates.” (Design: the asset graph.) update rebuilds nothing; the pair is update, then run.

  6. “chartbook run pins anything not yet pinned, so neither verb is needed to get started.” True as built, for build. Any other script pins nothing.

  7. “build still means ‘render the website’ and keeps doing so until run exists; the rename is one breaking change, made once, in the same release as run.” (Design: the asset graph.) run exists and the rename is 4c. Both are unreleased, so “the same release” still holds, and until 4c chartbook build and [scripts] build mean different things.

  8. The dodo.py in 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”). Under chartbook run it works, because the pin is made before doit starts. Run as plain doit in a fresh clone, it still fails.

  9. The Build record is one per asset, with a recipe_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

chartbook/snapshot.py, make(), uncommitted()

Where a folder is in git

chartbook/snapshot.py, code_of()

The newest snapshot at or before the commit a folder is on

chartbook/snapshot.py, newest_for()

One shape for an asset’s record, in a snapshot and in a lockfile

chartbook/snapshot.py, asset_record()

Pinning a path dependency from a snapshot

chartbook/lock.py, _sync_from_snapshots()

A snapshot is never cleared

chartbook/lock.py, plan_gc()

The commands

chartbook/cli.py: snapshot

The record’s schema

schemas/chartbook/snapshot/v1.json

The snapshot’s commit and time, on a dependency in the index

chartbook/index.py, schemas/chartbook/v1/Pipeline.json

The tests

tests/test_snapshot.py; the examples, in tests/test_integration.py

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.

  1. The command is chartbook snapshot, run in the pipeline, with no argument.

  2. “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.

  3. 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.

  4. A path dependency follows the branch its folder is on. pin and update take 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.

  5. 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.

  6. A dependency is pinned to one snapshot, whole. pin does not take one asset from a newer snapshot and leave the rest. This replaces, for a path dependency, 3c’s “pins what has no pin yet: an asset the other pipeline has started to declare”.

  7. pin says what a newer snapshot would change, and stays. It no longer hashes anything to find out: it compares two records.

  8. update drops 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.

  9. 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 pin already did. A pipeline that has built nothing has no snapshot to make.

  10. The lockfile names the snapshot: its commit in location.commit, where a git dependency’s commit already is, and its time in snapshot. So the lockfile says which code as well as which data, for both kinds of dependency. The index carries both.

  11. With no snapshot, nothing is pinned, and the message says what the owner has to do. chartbook run stops there, before the build.

  12. The record is JSON in the index’s format, like the lockfile, with its own schema.

  13. 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.

  14. chartbook store gc never removes what a snapshot names, whether or not anything pins it yet. A record that cannot be read stops the clearing.

  15. The buried option is --from-folder, on pin and update, hidden from --help. It does what both did before 4b. The lockfile then names no commit, and a plain update afterwards 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.

  16. 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.

  17. builds/ in the store stays a name that is kept free. Records go in snapshots/.

Known gaps in 4b.

  1. 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 snapshot by itself still takes its owner’s word, and the record says which of the two made it.)

  2. 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.)

  3. A snapshot is on one machine. A colleague, or CI, has none of them. For a path dependency they make their own in that folder. A shared store is the answer, later. (Closed 2026-10-14: “4g, as built”, below.)

  4. A lockfile from one machine cannot be put back on another for a path dependency unless the same bytes are built there. pin reports a pin whose bytes are not in the store as lost.

  5. chartbook check reads 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.

  6. A git dependency does not use a snapshot. If its owner made one at that commit on this machine, chartbook run still builds the commit again in a clean copy.

  7. The examples need a clean folder. cd examples && doit makes a snapshot of yield_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.

  1. “Keep the boring files. path stays 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.

  2. “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.

  3. 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.

  4. “pin puts back what the store has lost, when the other pipeline still has the same bytes” (3c, proposed 5). For a path dependency it no longer can, because it does not read the folder. A snapshot is what puts bytes in the store.

  5. “pin hashes 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 a path dependency 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

chartbook/run.py, run(snapshot=True)

The check that everything is committed

chartbook/snapshot.py, require_committed()

What a snapshot was built with, and from

chartbook/snapshot.py, _built_with(), _inputs()

The tests

tests/test_run.py (TestRunThenSnapshot), tests/test_snapshot.py (TestBuiltWith)

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.

  1. One spelling for both together: chartbook run --snapshot. (Decided, 2026-10-13. He had floated two equivalent spellings, that and chartbook snapshot --run. One: it reads in the order it happens, run already has --force so run --force --snapshot is the weekly refresh in one line, and two spellings are two things to document.)

  2. 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.

  3. The snapshot records that the build ran in the same command, built_with.run. One made by chartbook snapshot alone says false.

  4. 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.

  5. A snapshot is of what build makes. chartbook run test --snapshot is refused.

  6. 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.

  7. inputs: a snapshot says what it was built from. The dependencies of the pipeline’s own lockfile, each with its commit, snapshot time and pinned_hash. This is the design’s “hash each input’s hash”, at the granularity of a pipeline: a snapshot of rate_forecast names the snapshot of fed_yield_curve it read.

  8. 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.

  9. 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.

  1. The Build record’s tools and impure (Design: the index) are what built_with is a first, smaller form of. impure is not recorded: nothing declares it.

  2. “Make the hashes transitive … hash each input’s hash, not its path or name.” (Design: the asset graph.) inputs records 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

chartbook/snapshot.py, history(), versions_of(), version_name()

A model’s scores kept in its snapshot record

chartbook/snapshot.py, make()

[models.x.aliases]

chartbook/manifest.py, validate_aliases()

model/x@7f3a9c2e41b0 and model/x@champion resolve

chartbook/resolve.py, _resolve_version()

chartbook.models.versions(), Version

chartbook/models.py

Versions and labels on the Model record, and on the model’s page

chartbook/index.py, chartbook/writers/sphinx.py, schemas/chartbook/v1/Model.json

A label whose version is not here

chartbook/check.py, check_model_labels()

The tests

tests/test_model_versions.py

Decided by the maintainer, 2026-10-13, before it was built:

  1. 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.

  2. 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 a v4.

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.

  1. 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.)

  2. 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.

  3. 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.

  4. A label points at the start of a hash, at least twelve digits. chartbook check warns 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.

  5. 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.

  6. @ 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.

  7. A snapshot says when it makes a version: model/x: new version 7f3a9c2e41b0.

  8. 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.

  9. 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.

  1. 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.

  2. The index and the page depend on the machine that built them. Two machines build different versions lists from the same commit.

  3. 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”.)

  4. 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.

  1. “Recorded in the pipeline’s committed chartbook.lock so 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.

  2. “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@champion and gets a path” works on the machine that made the snapshot.

  3. “@rev resolves” 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

chartbook run

Yes

No

chartbook docs

No

Yes

chartbook build

Yes

Yes

What

Where

chartbook docs: what chartbook build was

chartbook/cli.py, docs(), _render_site()

chartbook build: run, then docs

chartbook/cli.py, build()

A script is told which pipeline and script it is

chartbook/run.py, execute(), running()

The tests

tests/test_build_command.py

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.

  1. chartbook build takes exactly the arguments of chartbook docs. It does not take chartbook run’s --force. To rebuild a git dependency and then render: chartbook run --force, then chartbook docs.

  2. With no build script, chartbook build says so and renders the site. A project that only documents files it already has is not an error.

  3. If the script fails, the site is not rendered, and the exit code is the script’s.

  4. In a catalog, the script is run in each of the catalog’s pipelines, and then the catalog’s site is rendered.

  5. 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 build called from inside the build script it is running renders the site and prints a warning that says to use chartbook docs. chartbook run called from inside the script it is running is refused. Without this, an old build script would run itself without end.

  6. chartbook catalog build is unchanged. It renders the global catalog’s site and runs no script.

Known gaps in 4c.

  1. The project template still says chartbook build. It is in another repository (cookiecutter_chartbook), which this work does not touch. Its dodo.py needs 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 says chartbook docs. The old repository is left as it is, for the released versions that render it. “The project template, moved in”, below.)

  2. An old dodo.py started with plain doit runs 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.

  3. 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 plain doit no longer makes the site.

  4. chartbook catalog build now disagrees with chartbook build about what “build” means. What it should be called is not settled.

Where the design said less than it seemed to.

  1. “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.

  2. “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

chartbook/snapshot.py, plan_removals()

The folder a snapshot was made from, noted beside the records

chartbook/store.py, FolderStore.note_origin(); chartbook/snapshot.py, origins()

Labels read from that folder’s manifest

chartbook/snapshot.py, _labels_in()

Removing a record, then what only it named

chartbook/lock.py, plan_gc(), run_gc(); chartbook/snapshot.py, remove()

The command

chartbook/cli.py, store gc --keep-days

The tests

tests/test_snapshot.py, TestClearingOldSnapshots

Proposed: implemented, and waiting on the maintainer’s yes. All of it; the maintainer asked not to be consulted on rules of this size.

  1. 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: pin chooses by commit ancestry, so a release branch’s last snapshot is what its users pin, and the plain “newest” would have removed it.)

  2. Thirty days. Long enough to come back from a holiday to; short enough that a nightly build does not fill a disk. --keep-days moves it, down to zero.

  3. “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.

  4. The store notes where a snapshot was made from, in a file origins beside 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 how gc finds the manifest whose labels say which versions to keep.

  5. 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.)

  6. A lockfile that cannot be found keeps any snapshot that holds an object it named. That is as much as is known about it.

  7. 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.

  8. The last record at a commit takes its folder and its origins note with it.

  9. Nothing is removed if any record cannot be read, as for a lockfile.

  10. The plan is made again when the yes comes, and only what both plans say may go is removed. A pin that landed while someone was deciding is honoured. (Third review.)

  11. 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.)

  12. The last record at a commit leaves a half-written one alone. Only the origins note goes with it, and the folder if it is then empty. (Third review.)

Known gaps.

  1. 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 gc on this machine then removes. chartbook check there says the version is not on this machine.

  2. 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.

  1. 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 is outside, not pinned, and a problem. This is the boundary 3e implied and did not enforce: pin is safe, run is trust. A path dependency pinned --from-folder is not checked: it is a folder on this disk that the user chose.

  2. 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.

  3. The lockfile ChartBook writes does not count as an uncommitted change for run --snapshot, before or after the build. The snapshot’s inputs record what it says. A build that makes a commit gets no snapshot.

  4. The recursion guard sees the whole chain of scripts, in CHARTBOOK_RUN_STACK, and comes before the pin.

  5. 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.

  6. 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 snapshot at 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 init renders whatever is on main, 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.

  1. 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.

  2. 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.

  3. 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 the Series records 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.

  1. 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’s inputs name a series. It stays reserved, with its old message.

  2. A file inside a model, model/x#weights.pt, is not built either (slice M2 of Design: models), by the same reasoning: it is models.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

template/: cookiecutter.json, hooks/, {{cookiecutter.project_slug}}/

Where chartbook init gets it

chartbook/scaffold.py, find_template(): the package’s own copy (chartbook/template in the wheel), or template/ of a source checkout

The template’s own tests, and a build of a fresh project

tests/test_template.py

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.

  1. 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 init renders 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, as chartbook/template in the wheel.)

  2. A project is still made with cruft, and its .cruft.json records this repository and the template folder, so cruft update goes 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.json is written as cruft writes it, with the commit the ChartBook was built from, which a build hook records in the package.)

  3. The template was copied, not merged in with its history. It came from cookiecutter_chartbook at commit a67eeea, and that is written here and in the test file. The old repository has the history.

  4. 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 with jupyter-book now say chartbook docs -f.

  5. 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.

  6. The old repository is left exactly as it is. Released versions of ChartBook render it, and there chartbook build still means “render the site”. Changing it would break them. Nothing in this work touches it.

  7. 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.

  1. A project gets the template as it is on main today, whatever ChartBook is installed. That was true before the move and still is. Someone with an older ChartBook who runs chartbook init after a later change to the template can get a build file that uses a command they do not have. Pinning what chartbook init fetches to the installed version needs releases to be tagged in git, which they are not. Proposed: tag them, and have chartbook init ask cruft for that tag.

  2. chartbook init still needs the network.

  3. Existing projects point at the old repository. cruft update in them keeps reading it until they are re-linked: cruft link https://github.com/backofficedev/chartbook --directory template.

  4. A project straight from the template fails chartbook check on one thing: it has no repo_url, because it has no git remote yet. chartbook init --github gives it one. This was so before the move; the new test is what showed it.

  5. The template asks for chartbook>=0.2.0, the version of the ChartBook it comes with (a test keeps _chartbook_version equal to it): a project made from it needs the release that has chartbook docs, and 0.2.0 is it.

4g. --frozen, and a store a team shares — as built#

Added 2026-10-14. Built after step 5, by my choice, because the maintainer’s purpose for all of this is people and agents working in parallel around a store that is read-only for most of them and updated deliberately; this is the piece that lets such a store be shared and lets a machine that may only read it build.

What

Where

verify(): can the pipeline be built from its lockfile alone, nothing written

chartbook/lock.py, FrozenReport, FrozenProblem

chartbook pin --frozen, chartbook run --frozen

chartbook/cli.py, chartbook/run.py (frozen=, on_verify=)

A read-only store does not fail pin

chartbook/lock.py, _sync(), PinReport.unrooted

Labels travel with snapshots, so gc on another machine knows them

chartbook/snapshot.py, Snapshot.labels, labels_of(), _labels_in()

The tests

tests/test_lock.py (TestFrozen), tests/test_run.py (TestFrozen), tests/test_snapshot.py

What --frozen checks. The lockfile exists if the manifest has dependencies; every dependency in the manifest is in the lockfile, and the other way round; a dependency is pinned from where the manifest now says it is (_declared_elsewhere); each has something pinned; each asset has a content hash, that is, was built when it was pinned; and the store has every hash. It opens no repository and uses no network. run --frozen skips the pin and the building of git dependencies, runs verify() in their place, and refuses to build on a problem. Since 2026-10-17 it leaves, where the store can be written and not under CI, this machine’s note of the lockfile for store gc, so that a machine that only reads keeps what it reads; it writes no lockfile and no object.

Proposed: decided here, not by the maintainer.

  1. An asset not built when it was pinned is a problem under --frozen. pin records it and goes on, so that an upstream with one dataframe behind a subscription is still usable; the design said the stricter choice is what --frozen is for, and this is it.

  2. --frozen is a flag on pin and run, not a command, and not on check. check never reads the store or the lockfile; keeping that so is the reason, and the open question above asks whether CI would rather have one command.

  3. Labels travel with snapshots. A snapshot records the model versions its manifest’s [models.x.aliases] named when it was made, and gc reads the newest snapshot’s as well as the folder’s manifest. So a label given on one machine is known to a gc on another, as of the newest snapshot; and only the newest counts, so that a label taken away releases its version once a snapshot follows (a second review found the first version of this read every snapshot’s labels, which kept a version for ever). The first design here kept every snapshot whose folder this machine cannot see; the first review found that a CI runner’s checkout is such a folder the moment the job ends, so that rule would have kept every nightly snapshot for ever, and it was replaced by this. What is still not known across machines: a label given after the newest snapshot, and which snapshot a branch tip on another machine pins from.

  4. A read-only store is a note, not a failure, for a path dependency pinned from a snapshot. The only write that pin needs in the store then is the root note for gc; so the store’s owner is the one who runs gc, and a pin that could not leave the note says so once. Only a permission error is treated so; a disk that is full is still an error, and a half-written note is removed. What has to write the store (a git dependency’s first pin, --from-folder, snapshot) stops with a message that says the store is read-only and what to do.

  5. Nothing new in the store’s layout. Sharing is a path in settings.toml, which existed. Snapshots were already the store’s own bytes (snapshot.LINKS is reflink then copy), which is what makes a shared disk work; it is recorded here because it was found, not made.

What an adversarial review found before merge (2026-10-14; eleven findings, each fixed with a test unless said otherwise): the gc rule above, which would have kept every CI snapshot for ever (replaced); “a read-only store still pins” was true only for a path dependency from a snapshot, and a git dependency’s pin failed with a bare errno (the message now says the store is read-only and what to do; the docs say which pins write); every OSError from the gc note was reported as “read-only”, so a full disk passed silently and left a half-written note (the catch is permission errors only, the note is whole or not at all); run --frozen skipped verify() when the manifest had no dependencies, although the lockfile might still list one, and --frozen on a non-build script was ignored (both fixed: run --frozen always verifies; a non-build script with --frozen is refused); verify()‘s remedy for a pin the store lacks said chartbook pin, which never re-stores a path dependency’s lost pin (the remedy now depends on the kind); --forget-missing on a shared store discards other machines’ pins and nothing said so (the help, the hint and the docs do); the store gc reference and docstring listed the old rules (updated); the read-only note was wrong on the remove_root path (reworded); the read-only test only chmod’ed roots/ (a fully read-only store is tested, from a snapshot); a lockfile entry without an id was a KeyError (a LockError with a remedy); a docstring’s rule numbers.

A second review, of those fixes, found seven more, all fixed: labels recorded in any snapshot kept a version for ever, even after the owner took the label away (the newest snapshot’s labels count, and no other’s); chartbook snapshot on a read-only store still printed a bare errno; the remedy for a lost pin was wrong for a --from-folder pin, and its test asserted the wrong remedy (it depends on the kind of pin now: repository, snapshot or folder); a full disk after the lockfile was written lost the report (a LockError says the lockfile was written and the note was not); run --frozen on a leaf pipeline said “0 pin(s) of 0 dependencies” (“no dependencies to check”); the snapshot record’s labels was in no schema and no table; the store gc docstring left out the branch-tip rule.

Known gaps. A shared store is cleared by its owner, from the machine the snapshots were made on, and without --forget-missing: another machine’s lockfile cannot be found from here, so a writable roots/ is what lets its pins be known, and a label given after the last snapshot, or a branch tip on another machine, is not known at all. The origins note and the roots note hold paths from the machine that wrote them. A lockfile does not say which store its pins went into; --frozen checks the store this machine is pointed at. Concurrent writers to a shared store rely on each file being written whole and then renamed into place, which is what the store does; nothing locks the store as a whole.

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: {"model/x": {"champion": "7f3a…"}}

chartbook/snapshot.py, Snapshot.labels, labels_of(); schemas/chartbook/snapshot/v1.json

A label becomes a lockfile record of its own, key:model/x@champion, with the version’s hash

chartbook/snapshot.py, label_records(), versions_of_pipeline(); chartbook/lock.py, _sync_from_snapshots()

other:model/x@champion resolves through the lockfile; @7f3a… through the store

chartbook/resolve.py, _resolve_pinned(), _resolve_pinned_version()

The tests

tests/test_model_versions.py, TestAnotherPipelinesVersion

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.

  1. 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 a label field. 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.

  2. 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 by gc if no label and no lockfile names it.

  3. A label whose version the store cannot supply is pinned without bytes, with the reason in the record (missing) and in every message, reported as not built, and a problem under --frozen. pin goes on with the rest.

  4. pin reports a label the owner has moved since as stale, like changed data, and does not move it; update does, as moved; a label taken away is dropped on update.

  5. Snapshot.labels changed 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.

  6. A git dependency’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: trained_on on the model’s record, {input_id: content_hash}

chartbook/snapshot.py, training_inputs(), make()

What the model file is now, against that

chartbook/models.py, staleness(), Staleness

The index: trained_with, stale_inputs on the Model record; inputs_now beside them since 2026-10-15, so the record alone says which input moved from what to what

chartbook/index.py; schemas/chartbook/v1/Model.json

The page: “Out of date”, with each input then and now

chartbook/writers/sphinx.py, _staleness()

check warns

chartbook/check.py, check_model_staleness()

The tests

tests/test_model_versions.py, TestStaleness

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.

  1. The record is the snapshot’s. pinned_inputs on 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.

  2. 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.

  3. 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.

  4. 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

[sources], and a dataframe’s table or query

chartbook/manifest.py, validate_sources(), validate_dataframe_location()

The two new location kinds

chartbook/resolve.py, TableLocation, QueryLocation, _resolve_in_source()

The engines: parquet, and DuckDB

chartbook/backends.py

data.sql(), data.ref(), data.connect(); load() of a table or query

chartbook/data.py

Where a machine keeps credentials

chartbook/backends.py, source_url()

The index: location kinds, definition_hash, columns from the engine

chartbook/index.py, schemas/chartbook/v1/Dataframe.json

The page

chartbook/writers/sphinx.py, _source_glimpse()

check: a DuckDB file not built

chartbook/check.py, check_sources()

The example

examples/yield_curve: yield_slopes, src/build_duckdb.py

The tests

tests/test_data_access.py

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.

  1. Cross-pipeline references work in data.sql() and data.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.

  2. 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 through data.connect(). A query that names two sources is refused and says so.

  3. duckdb is a dependency of chartbook, not an extra, by the one-install rule.

  4. A url with credentials in it does not load. The design said “no credentials, ever”; the loader enforces it.

  5. An unknown backend is refused by name. No plugin seam yet: DuckDB is the second implementation after parquet, and the rule is three.

  6. 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.

  7. The default policy’s path requirement is met by a table or a query. The rule asks where the dataframe is; a source answers.

  8. check reports a DuckDB file that is not built, and skips that with --skip-outputs, as for any output. It does not connect.

  9. 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):

  1. 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.

  2. A query that bound but failed when run (a cast that fails on one value) aborted the whole site build; describe and count(*) 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 as ComputeError or lets DuckDB’s exception through depends on the Polars version (CI’s newer Polars did the second), so the page catches Exception and the docs promise the message only.

  3. One in-memory connection was shared by every :memory: source and by files-only data.sql(), so a table a user made through connect() 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.

  4. A query spliced as (sql) broke on a trailing ; or -- comment; it is (\nsql\n) now, with the semicolon dropped.

  5. A table or query of another pipeline was reported as “had no dataframe named … update pins 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 missing path; it resolves the table.

  6. A query with a newline or a | broke the manifest table on its page; the SQL is in a fenced block below it now.

  7. {'a': 1} and '{x}' in data.sql() were read as references; a doubled brace is a literal.

  8. 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.

  9. duckdb>=1.1 was 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.

  10. duckdb://host/x became a folder, ?options part of the file’s name, and user@ passed. A DuckDB URL is a file and nothing else; the rest is refused with a reason.

  11. A typo in table left the record silently without columns; the index warns.

  12. The page’s first and last dates were None for a table with a date_col; the engine is asked for min and max.

  13. path = "" with a source passed as a file; presence is tested, not truthiness.

  14. definition_hash left out the source, so the same table name in two sources hashed alike.

  15. 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.

  1. 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 is panel(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.

  2. 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.

  3. 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 Build records 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, not pytest directly. hatch test --all runs 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 in gallery.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.py writes 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/. Update llm/llms.txt and llm/llms-full.txt when commands change.

  • The examples are tests. cd examples && doit must build both example pipelines and the catalog, and chartbook check must 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 [project] table, scoped pipeline IDs, policy in the catalog

Design: chartbook.toml Format v2

2026-10-11

The asset graph and the index. ChartBook publishes and never orchestrates

Design: the asset graph, Design: the index

2026-10-11

Dataframes may live in databases; ChartBook substitutes names and never rewrites SQL

Design: data access

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

repo_url defaults to the git remote. “Code updated” comes from the last git commit and is shown only on the pipeline’s page

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 <pipeline>/_data/_scratch/

Changelog, “Earlier in this release”

2026-10-12

The stability policy is one line: experimental, anything can change, breaking changes are in the changelog

Stability

2026-10-12

Asset names are per kind, not one namespace. Data is the spine

Design: references

2026-10-12

The kind goes in the address (chart/gdp), dataframe is the default, the four separators stay, and one parser produces a structured object

Design: references

2026-10-12

chartbook.assets is the general module. chartbook.data is dataframes only. chartbook.models will follow the same pattern

Design: references

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 generated_at is when it last changed

Design: the index

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

Design: the index, Configuration Guide

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 check reports a model that does not say what it was trained on. A model’s write-up is strongly recommended, not required

Design: models

2026-10-12

Models are assets. ChartBook is a model registry, not an experiment tracker. ML operations is a central use case

Design: models

2026-10-12

The store is one per user, at ~/.chartbook/store/, movable with CHARTBOOK_STORE or settings.toml. One directory per asset, named by its hash, files under their own names

“Answers to the open questions”, above

2026-10-12

The store links by reflink, hardlink, symlink, copy, in that order, as designed. chartbook store verify is what catches an object changed from outside

“Answers to the open questions”, above; Design: the asset graph

2026-10-12

chartbook.lock is JSON in the index’s format: no timestamp, no absolute path

“Answers to the open questions”, above

2026-10-12

chartbook pin pins what is built and says what is not; asking for an unpinned asset is what fails

“Answers to the open questions”, above

2026-10-12

Hashing a directory skips .DS_Store, Thumbs.db, desktop.ini and ._*, and nothing else

“Answers to the open questions”, above; Design: the index

2026-10-12

chartbook pin never moves an existing pin. chartbook update is what moves one

“3c, as built”, above

2026-10-12

chartbook store gc keeps what the lockfiles registered in the store name, keeps the pins of one it cannot find unless told to forget it, and asks before removing

“3c, as built”, above

2026-10-12

A pin is frozen: pin and update ask the store for a reflink, then a copy, never a hard link or a symlink. The store keeps all four kinds

“3d, as built”, above

2026-10-12

data.load() takes one reference, data.load("pipeline:name"), beside the two-argument catalog form

“3d, as built”, above

2026-10-12

A Model record names what its cross-pipeline inputs are pinned at in pinned_inputs, beside inputs

“3d, as built”, above; Design: the index

2026-10-12

examples/rate_forecast git-ignores its lockfile, because its upstream pulls live data

“3d, as built”, above

2026-10-12

git dependencies are built now, plainly: clone, check out, pin what the repository commits, and say that is all a clone has

“3e, as built”, above

2026-10-12

Clones of git dependencies are kept in ~/.chartbook/git/, one per user, and ChartBook runs the user’s own git

“3e, as built”, above

2026-10-13

chartbook run never moves a pin. It pins what has no pin and leaves the rest; chartbook update moves pins

Step 4 above; Design: the asset graph, “Two verbs”

2026-10-13

chartbook run builds a git dependency, in a writable copy of its checkout, once for a commit, and its built data is pinned from there. --force builds it again: data is refreshed on purpose

Step 4 above, “4a, as built”

2026-10-13

A pipeline never reads, hashes or builds in a folder it depends on. chartbook run builds the pipeline it is run in

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. pin and update choose among snapshots

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, [models.x.aliases]

Step 4 above, “4d, as built”; Design: models

2026-10-13

Building and making a snapshot together has one spelling, chartbook run --snapshot. Recording named environment variables in a snapshot waits until a pipeline needs it

Step 4 above, “4b, continued”

2026-10-13

A reference never selects data. No language for loading: SQL passed through, or data.path() to polars. data.load("dataframe#series") will not be built; a series address is for documentation

Step 4 above, “4e”

2026-10-13

The project template moves into this repository, as template/

Step 4 above, “The project template, moved in”

2026-10-13

The command rename is made now and is breaking: chartbook docs renders the site, chartbook build runs the build script and then renders. No compatibility beyond a warning

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; stale_inputs stays

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 (sitefiles.write_site()), the generator is gone (#58–#63). The documentation site is written in the vocabulary: front page with diagrams of the chain and the four documents, the build page around resolve → write → build, a page for depending on other pipelines (branch docs-site-6f). Decided along the way, all proposed: a write-up is text, not a template; a skipped pipeline is not part of the site; asset keys must be addresses; inputs_now, site_pages, Catalog location in the index

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 stores-and-backends.md; settings layers and the store interface first

Jeremy, me

2026-10-16

The S3 store is built behind chartbook[s3] (#68): the bucket laid out as a folder store, read through a cache on the machine so readers take paths as before; data.path() is the cached copy. A URL-returning get() is left open, not built

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 versioned-catalog.md; the self-sufficient snapshot first

Jeremy, me

2026-10-25

Dependencies between pipelines must form a directed acyclic graph. chartbook run, chartbook build and chartbook snapshot refuse a cycle (a run before it pins or builds anything, with the pins it would make worked out first and not written, lock.would_pin; a pin is held back until it is let through, so a refused one writes nothing), and chartbook check reports one, naming who depends on whom and where it was read. The graph is read from the folders on this machine first (a cycle is found as soon as it is declared) and through the pins and snapshots in the store otherwise (chartbook.lineage.cycle)

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 git dependencies (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 --snapshot builds first, and “Clearing old snapshots” says how old ones go. (Step 4 above, “4b, as built”)

  • Whether chartbook check should 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 build should be called now that chartbook build runs 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 git dependency’s built data to get here. The first is built: chartbook run builds 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.