Design: the versioned catalog#

Note

A design document, written 2026-10-16 after the maintainer and the last person to work on this talked through what an index is for and what a snapshot is for. The conclusion is here, with the plan it leads to. Status: the self-sufficient snapshot (step 1) is built; the rest is proposed. What the maintainer decided is marked decided.

The use cases this serves#

  1. A project grows and is split into repositories. One depends on the outputs of another, and the dependencies diverge: B reads A at one version while C reads A at a later one.

  2. Depending upstream must be reproducible and show its lineage: which version of what, by commit and by hash.

  3. One place to browse everything a person is working on, across every pipeline and every version of each, with links that follow the real dependencies.

  4. An assistant browses the same thing from the command line.

  5. While working on one repository, its own site is rebuilt now and then, for the author and for collaborators, from whatever is in the folder.

The first two exist: declared dependencies, pins by commit and hash, snapshots in the store, the provenance panel. The fifth is today’s pipeline site. The third and fourth are what this page plans.

Index and snapshot: the conclusion#

Decided 2026-10-16. The two documents stay two documents, and the record format they share is the only merge. They are different acts:

A snapshot

An index

Is

A preservation act: the bytes one pipeline built at one commit, copied into the store, declared ready

A description of what a site shows, taken from whatever is in the folders now

Needs

Everything committed, the bytes present

Nothing committed; a pipeline with missing outputs can be skipped

Covers

One pipeline, once

Many pipelines at once, the catalog’s own facts, and comparisons no one pipeline can make: a model out of date against its inputs now, versions read across commits

Lives

In the store

Beside the site, and a copy without machine paths travels with it

Neither is derivable from the other. A snapshot cannot be made from an index: the index has hashes and not bytes, no commit, and may describe a half-built folder. An index can almost be assembled from snapshots, but only from every pipeline’s snapshots plus the files at those commits plus the store, and the result describes what was declared ready rather than what is in front of you. That is a different site, and a useful one; it is the catalog below.

What was wrong before this conversation: a snapshot could not render a site. The index records facts and pointers, not the write-ups, notebooks, or chart files, and those came only from a checkout. So a version’s site, and a version’s data dictionary as it stood when the data was made, lived in git and nowhere else. The maintainer’s point: the snapshot of the data should carry the description of the data.

So a snapshot holds the index of its version. Not a merge; nesting. A snapshot contains the pipeline’s own index at that commit and the small documentation files a writer needs; a site’s index is assembled from version indexes plus the comparisons only a site can make. The vocabulary page says this now, where it used to say the two “converge”.

The catalog shows every version of every pipeline#

Decided 2026-10-16. A catalog shows every snapshot in the store of every pipeline it knows, found by pipeline ID, plus the working version of each pipeline whose folder is on this machine: the folder as it is, committed or not, built or not, which is what the pipeline’s own site shows too. The store is the source of versions; the catalog’s manifest is the source of working copies, as it is today. A catalog with no snapshots is today’s catalog, so nothing breaks. “Latest” is not a word here: the working version is the working version, and the newest snapshot is the newest snapshot.

What that needs:

  1. Which versions. Every snapshot of every pipeline the catalog lists, and the working version of each whose folder is present. A snapshot that store gc removes leaves the catalog with it: gc is the lifecycle.

  2. Pages per version, each set under a path that carries the pipeline ID and the version: the commit’s start and the snapshot’s time, since one commit may have several snapshots when a pull script brought other data. Static pages are cheap.

  3. Links follow pins. C’s pages link to A at the snapshot C pinned; B’s to the other. The link scheme is a function of the writer for this reason.

  4. A version list on every pipeline page: the working version first, then snapshots newest first, with the commit and the date, each a link to the same page in that version or to the version’s front page where the page does not exist there. Both writers render it as a list; a drop-down is theme work.

  5. Comparisons stay within a version. An out-of-date notice compares what a version recorded with what it pinned, never with a version it did not pin.

  6. Rendering a snapshot costs once. A snapshot never changes, so its pages are rendered the first time a catalog build sees it and kept; only working versions are rendered every build. The incremental build is the home for that rule.

  7. Search is scoped to working versions and newest snapshots by default, or every hit is a near-duplicate.

  8. Diagnostics and policy apply to working versions; a snapshot’s metadata is history.

A shared store makes a shared catalog: with the S3 store, a team’s catalog is built from the bucket, and that is the use case for several machines (Design: stores and backends).

For the assistant#

The catalog’s index.json is the assistant’s entry point already. Generated sites should also ship an assistant file written from the index, one line per pipeline, dataframe, chart and model with where its page is, so that a command-line agent finds its way without scraping.

The order of work#

  1. The self-sufficient snapshot. The snapshot carries the pipeline’s own index at that commit, in the published form, as an object in the store, and the documentation files a writer needs that are not assets: the manifest, the lockfile, the README, every write-up, the pipeline’s own site pages, the logo and favicon. Notebooks, notes, charts, dataframes and models are assets already. A snapshot can then be laid out as a folder from the store alone and a site built from that folder, which is the test. Built 2026-10-16: Snapshot.index and Snapshot.documents in the record, written by chartbook snapshot, kept alive by store gc like the assets; chartbook.snapshot.materialize() lays a snapshot out as a folder, and a test builds a site from one with the repository gone. As built, after a review and a live run on the examples:

    • What is taken, and how. Documents are named by the manifest’s text, so a manifest a docs build refuses still keeps them; the index is best-effort for the same reason, and the command says in one line why it was left out (it is not tried again at that commit unless the bytes change). Only files git tracks are taken, asked of git with literal pathspecs; a path must be plain (safe_relative: nothing absolute on any system, no .., no part named .git, no :), and no file that is a link or under one is taken; each one not kept is said. The review found that without these, glob characters, git’s pathspec magic and tracked links each reached files outside the folder.

    • A record is untrusted. A store may be shared, so materialize judges every path in a record by the same rule, follows no link inside an object, writes no .git, and writes nothing until every object the record names is there.

    • A laid-out version keeps its identity. materialize writes .chartbook-version.json (chartbook.identity.VERSION_FILE): the pipeline, the commit, when it was made and when its code last changed, from its index. A pipeline whose ID came from its folder’s name and its git remote keeps that ID in a folder of any name, and its code time is not asked of a git it does not have. The live run found both missing.

    • What a build writes and git does not keep is written back where the snapshot recorded it: a model’s scores to its metrics_path; a site_dir the manifest names is made if none of its files were kept. A table or query dataframe is in the store as parquet while the manifest names its database, so its page in a laid-out version says it is not built; the catalog’s renderer (step 2) will read the parquet instead.

    • Cost. Making the index hashes every file again, as a docs build would; for large data that doubles the hashing a snapshot does. Accepted for an index that says exactly what a build of that commit would say.

    • A second review found more, all fixed with tests and every exploit script rerun: a tracked decoy whose name differs from an ignored file’s only by a trailing space or a \ (git’s names are now taken exactly, never put in order, and decoded losslessly); .git aliases that HFS+ and NTFS make of other names (the rule is now git’s own: case, the invisible code points HFS+ drops, trailing dots and spaces, git~1), with a last check after layout that removes any .git and refuses the folder; a store object folder that is itself a link (the folder store now refuses one); a file swapped for a link between being looked at and being read (a document is opened without following a link and must be the very file looked at); and the version file honoured where it does not belong (it is read only in a folder that is not a repository’s own and where git does not track it, never when too large, and a laid-out version never has git asked about its code time). A named file git does not track is now said too.

  2. The catalog renders snapshots. Working copies as today, plus the newest snapshot of each pipeline, each version at its own path. This proves rendering from the store. Built 2026-10-16, with every snapshot rather than the newest: a version’s site is built once on a machine and kept, so all of them cost what one did. Each Pipeline record of the index lists its snapshots in the store, newest first. A catalog build lays each one out from the store (materialize), builds it as the pipeline’s own site in a process of its own, with the site tool its manifest names, keeps it under versions/ in the machine’s cache, keyed by the ChartBook version, and copies it into the catalog’s site at versions/<pipeline>/<commit>-<made_at>/. A version that cannot be built gets a page that says why, and a warning: line, so a link to it never leads nowhere. A laid-out folder’s Pipeline record says from_snapshot, and its front page says which snapshot it is. Built otherwise than item 4 of the section above: the version list is one catalog page, cb/versions.md, every pipeline with its working version and its snapshots, the commit, the date, whether chartbook run built it and which pipeline of the catalog pins it; not a list on each pipeline page, because the catalog’s own pages are written on every build and a pipeline’s page is not when an incremental build reuses it. Search is scoped by construction: each version is its own site, so the catalog’s search finds the working versions only.

    After the review, 2026-10-16. Building versions automatically means running a docs build on files anyone who can write the store may have written, so a version’s build runs as a build of files that are not the user’s own (chartbook.config.untrusted_source): every template, a project’s own included, renders in Jinja’s sandbox, and a version takes no template from its folder at all; its site folder gives only pages and pictures; no page takes in a file (file_insertion_enabled off) and no picture or download that names a file outside the site is copied; notebooks are never run, as in any build; and each version’s build has a time limit. Two holes the review found are closed for every build, not only versions’: conf.py is rendered from the package’s own template, never from the build tree where a site page named conf.py.j2 could stand in for it, and a site page may not be conf.py*, _templates/ or the tool’s configuration. A snapshot record is checked when read (a full commit hash, a time as a snapshot writes one, a pipeline ID, a plain subdirectory), and . and .. are no longer pipeline ID parts, so that no value in a record becomes a path outside the site or the cache; the review showed a forged made_at could otherwise delete and write folders anywhere. One record that cannot be read no longer hides every version; only a catalog’s index lists snapshots, so a pipeline’s published index carries no store history; the store is read twice per catalog build rather than once per pipeline; the kept sites are keyed by ChartBook’s version and a fingerprint of its code, so an edit to ChartBook builds them again; a version whose snapshot store gc removed is removed from the site, the incremental build’s tree included; and a staging folder a stopped build left is cleared.

    After a second review, 2026-10-16. The restrictions above held against forged snapshots, and the data path did not: a forged manifest could name a DuckDB query that reads any file of the machine, or a dataframe path outside the folder, and the version’s pages showed the rows. In a build of files from a store, ChartBook now runs no table and no query at all (DuckDB refuses to open; the snapshot holds those rows as parquet, and the page says so), and the manifest loader refuses any path in [project] or an asset table (path and every *_path) that is not a plain path inside the folder, so that version’s build stops and its page says why. Both forgeries were rerun against the fix: neither secret reaches the site.

  3. All versions. Every snapshot, links following pins, the version list, snapshot pages kept across builds, search scoped. What remains after step 2: links following pins, so that a page of C links to A at the version C pinned, inside a version’s site as in the catalog; a catalog entry by pipeline ID alone, for a pipeline whose folder is not on this machine; and clearing the kept sites, in the cache, of snapshots store gc removed. Built 2026-10-16, in part, after a review: the only input of another pipeline a page has is a model’s training data. In a catalog, a model’s “Trained On” links each such input to its page in the snapshot that held the bytes it was trained on (the index records it as trained_from, from trained_with and the store), and says when the pin has since moved; failing that, to the snapshot the lockfile pins for that input; failing that, to the working version, as before. The provenance panel links an input the lockfile pins to its page in the pinned snapshot; it linked nothing before. A link leads into a version only when the catalog shows that snapshot, and to the page that says why when its site could not be built, so the catalog builds the versions’ sites before its own pages. An incremental build regenerates a pipeline’s pages when any of this changes. store gc clears the kept site of every snapshot it removes. Not built: the same links inside a version’s own site, which is built once and kept, and so cannot know which other versions a catalog will show; its pages say the pinned commit and date in words. And a catalog entry by ID alone, which is the next step. Built 2026-10-16: a [pipelines] entry with no path names a pipeline whose folder is not on this machine; the catalog’s index has a Pipeline record for it with location: {kind: store}, named and described from its newest snapshot’s own index, and its snapshots; its page links its newest version and the Versions page; the manifest keeps it apart from the folder pipelines (store_pipelines), so check, run, the incremental plan and the policy pass it by. That completes step 3. Built 2026-10-16, after review: what a snapshot says of its pipeline is someone else’s text reaching the catalog’s own, trusted build, so the writer shows its name, description and maintainer as plain text (each ASCII punctuation character a character reference) and its links only when they are plain web addresses; the description is the newest snapshot’s, the one the page links. A [pipelines] entry takes only path and disabled, and a folder of the entry’s ID fills it in. A second review found that a page title (MkDocs) and Sphinx’s search results decode character references and set the text into HTML again, so HTML’s own characters (< > & " ', backtick) are replaced by look-alikes first; the stored index is read only as a regular file, not through a link, and up to a limit. Not changed: a version’s own site is its owner’s pages, raw HTML included, served with the catalog, as a listed repository’s pages are; the guide says so. A third review (2026-10-16): a character whose normalized form (NFKD) holds one of HTML’s own is replaced too, with look-alikes that no normalization turns back, and & becomes “and”; other text stays as written; a name of blank fillers is no name; the stored index is read up to 8 MiB. It also found that the S3 store wrote a file for any key under an object or under snapshots/, .. included, outside its cache: a key that is not a plain path is now no file. A fourth review found that the fix hid legitimate files named with : or \ on systems where those are ordinary (now refused on Windows only), that gc stopped on an object of stray keys, and that gc read whatever file a store’s root note named (now only a local chartbook.lock). A fifth found that a lockfile on a Windows network drive is noted by a network path, which gc then took as gone; a note gc does not read now keeps its pins and is never forgotten.

  4. An assistant file for generated sites, written from the index. Built 2026-10-16: it is called llms.txt, after the llmstxt.org convention that agents already look for, and sits beside index.json at the root of every site. The site’s writer writes it from the index: a title and a summary, then a list each of pipelines, dataframes, charts and models, each line the name linking its page, the ID, and the description cut to one line. A version’s file says which snapshot the site is; a catalog’s lists each pipeline’s versions, each linking that version’s own llms.txt, or the page that says why its site could not be built. Text from a record is one line, with what would start a link escaped, so that the only links in it are the writer’s (in CommonMark; a renderer that links bare addresses may link one in a description). After review, the same day: the summary is the file’s own words only, and a description is in quotation marks after them, since a description at the start of a line made headings and lists (a version site’s summary was its owner’s description); a version’s file says its names and descriptions are its owner’s; a header says how to use an ID; each version is a line of its own, the newest ten, then the versions page.

  5. A development build, later if wanted: a folder with gaps rendered with its assets marked not built and its notebooks unexecuted.

What this does not decide#

How a version is named on a page (the commit’s start and the date is the proposal); whether a snapshot should also carry template overrides from docs_src/_templates; whether the version list should be a drop-down in each theme; what the assistant file is called (decided 2026-10-16 when it was built: llms.txt).