Design: the index#
Note
A design document. The index is the artifact every other part of ChartBook reads or writes, so it is specified before anything that depends on it. See Design: the asset graph for the mechanism this serves.
Status: the first implementation is built (chartbook.index, chartbook.hashing): every
build writes index.json with Catalog, Pipeline, Dataframe, Series, Chart, Notebook,
Note and Model records, and each pipeline’s declared dependencies. Build records and
pinned hashes are not written yet; they come with the parts of the asset graph that produce them.
What the first implementation does says exactly what is and is not there.
Revised 2026-10-12, before any of it was built: IDs carry the asset’s kind
(…:chart/curve_today), every asset record has a structured ref, an asset’s whereabouts is a
typed location object, a Model record was added, and the Build record gained code. The
reasons are in Design: references, Design: models and Design: data access.
What the index is#
chartbook build produces two things: the write-ups (markdown, as authored) and an index (a
structured description of everything the catalog contains). (2026-10-14: the words are now fixed
in Vocabulary: a writer turns the index into pages for a site tool; the index
records facts and no renderings; the lockfile is never merged into it.) The index is a build product, not
source — the resolved, composed view of the manifests, with paths absolutized, cross-pipeline edges
resolved, defaults filled in, policy inherited and content hashes attached.
_build/
index.json # the resolved catalog
content/ # markdown as authored
site/ # Sphinx output — one renderer among several
The relationship is exactly dbt’s manifest.json to its .sql and schema.yml files. And dbt’s
entire third-party ecosystem exists because that file is stable and documented, which is the
outcome worth copying.
The index is the product; every renderer is a consumer
Sphinx renders it today. Another frontend could render it tomorrow. An MCP server serves it. A Dagster adapter reads it. A registry stores it. A diff of two of them is a review surface. Sphinx stops being an architectural commitment and becomes the default frontend.
Four properties are load-bearing:
Self-contained. A consumer reads the index and needs nothing else — no catalog lookup, no filesystem walk, no manifest parsing.
Versioned per record, so the format can change without a flag day.
Content-addressed. Every asset carries its hash, in the same record format the lockfile and a snapshot use. (2026-10-14: the lockfile stays its own document, an input to the build where the index is the output; the shared record format is the only merge.)
Diffable.
diff(before, after)answers “what did this build change?” — which assets moved, which hashes changed, which upstream inputs shifted.
File shape#
One JSON object: an envelope, then a flat array of self-describing records.
{
"schema": "chartbook/index/v1",
"generated_at": "2026-10-11T22:14:03Z",
"generator": { "name": "chartbook", "version": "0.2.0" },
"root": "ftsfr/catalog",
"records": [ ]
}
(Today the file is not under _build/. It is written beside the HTML, as docs/index.json, and
in the build directory. See What the first implementation does.)
schema versions the file; each record versions itself. This is the shape Kubernetes uses — a
list with its own version, holding items that each carry kind and version — and it has survived
a decade and hundreds of resource types.
Why per-record kind and version#
{ "kind": "Dataframe", "version": "chartbook/v1", "id": "…" }
{ "kind": "Chart", "version": "chartbook/v1", "id": "…" }
{ "kind": "Build", "version": "chartbook/v1", "asset": "…" }
One file holds mixed types. New record kinds need no restructuring.
Readers skip what they do not understand. An MCP server filters for
kind: "Dataframe"; a Dagster adapter that has never heard ofkind: "Note"ignores it rather than failing.Version is per record. Charts can move to
chartbook/v2while everything else staysv1. Otherwise every change is a whole-file bump and a coordinated upgrade for every consumer.
Twenty bytes per record, and the cheapest available insurance for a project that intends to stop breaking things before 1.0.
A JSON Schema is published per kind per version, under schemas/chartbook/v1/<Kind>.json. That
publication is what makes third-party consumers possible.
They are in the package, at chartbook/schemas/chartbook/v1/, with one for the envelope at
chartbook/schemas/chartbook/index/v1.json, and they are published with this documentation at
the URL each one’s $id gives, e.g.
https://backofficedev.github.io/chartbook/schemas/chartbook/v1/Dataframe.json. Each schema
fixes the fields this document names and allows others, because an asset record also carries
whatever else its manifest entry declares.
Conventions#
Identifiers follow the reference grammar in Design: references:
[registry/][scope/]pipeline:[kind/]name[#part][@rev]
Every record’s id is fully qualified — never local, never relative — so an ID is meaningful
wherever it is copied to. An asset’s ID carries its kind, unless it is a dataframe:
ftsfr/fed_yield_curve Pipeline
ftsfr/fed_yield_curve:treasury_rates Dataframe
ftsfr/fed_yield_curve:treasury_rates#dgs10 Series
ftsfr/fed_yield_curve:chart/curve_today Chart
ftsfr/fed_yield_curve:notebook/analysis Notebook
ftsfr/fed_yield_curve:note/methodology Note
ftsfr/fed_yield_curve:model/rate_forecaster Model
An ID never carries @rev. Which version a record describes is said by its content_hash.
Each kind has its own names, so the kind is what keeps …:gdp (a dataframe) and …:chart/gdp
(a chart) apart. An earlier draft of this document gave both the ID …:gdp.
ref. Every asset record, and every Series record, also carries the same address as named
fields, so that no consumer ever has to parse an ID:
"id": "ftsfr/fed_yield_curve:chart/curve_today",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve", "kind": "chart", "name": "curve_today" }
kind and name are always present; registry, scope, pipeline and part appear when
set. This is exactly chartbook.refs.AssetRef.to_dict(), and str() of the same object is the
id. Build both from one AssetRef; never assemble an ID by joining strings.
Record kinds and asset kinds. A record’s kind is capitalized (Chart). The kind in a
reference is lowercase (chart). For the five asset kinds one is the other, capitalized:
dataframe/Dataframe, chart/Chart, notebook/Notebook, note/Note, model/Model.
Catalog, Pipeline, Series and Build are records that are not assets.
Hashes are "<algorithm>:<hex>", e.g. "sha256:7f3a9c…". Never a bare hex string, so the
algorithm can change without ambiguity.
What gets hashed is fixed, because hash computation is one of the three things that must have exactly one implementation (Not doing):
A file: SHA-256 of its bytes.
A glob of files, or a directory: SHA-256 of a listing with one line per file,
<hex of the file's SHA-256>, two spaces,<path relative to the directory or to the glob's fixed prefix, with forward slashes>, newline; lines sorted by path (by code point, which is whatLC_ALL=C sortgives). The fixed prefix of a glob is its leading directories that contain no wildcard:_data/ratesfor_data/rates/**/*.parquet. This is the output format ofsha256sum, so anyone can reproduce it by hand, and a test does. A glob that matches one file is still a listing. (Decided 2026-10-12; implemented inchartbook.hashing. Once a release carries it, it must not change without a new algorithm prefix.)Which files count: every file, hidden ones included, except a short, fixed list that operating systems create by themselves:
.DS_Store,Thumbs.db,desktop.ini, and any file whose name starts with._, compared without regard to case. So opening a model’s folder in Finder does not change the model’s hash, and a Zarr dataset’s.zarraystill counts. The list applies to a directory and to a glob. Apaththat names one file is that file, whatever it is called. (Decided by the maintainer 2026-10-12, replacing “every file counts, hidden ones included”. No release carried the earlier rule, so the algorithm prefix is unchanged.chartbook.hashing.is_ignored.)
Location. Where an asset’s content is, as a typed object. Today there is one kind:
"location": { "kind": "file",
"path": "./_data/treasury_rates.parquet",
"resolved_path": "/Users/x/repos/fed_yield_curve/_data/treasury_rates.parquet" }
path— exactly as declared in the manifest, relative to the pipeline root. Stable across machines. This is what a human compares against their TOML.resolved_path— absolute, machine-specific. Convenient for a local consumer; meaningless to a remote one, and therefore never the field a registry stores, and never published to a website.
Design: data access adds "kind": "table" and "kind": "query" for dataframes that live in a
database. That a location is a typed object and not a bare path was settled there; this document
used to show path and resolved_path at the top level of each record.
A Pipeline record also has a location, with a different vocabulary: "path" or "git",
saying where the pipeline’s source comes from.
Docs. An asset’s write-up is "docs": { "path": "./docs_src/…md" } when the manifest gives
docs_path, and "docs": { "text": "…" } when it gives the text inline with docs. The
rendered key shown in the examples below points into the content/ directory of the target
layout; it is not written until that layout exists.
Declared metadata. An asset record carries every field its manifest entry declares
(sources, tags, license, units, a field of your own), as declared, with keys sorted. TOML
dates become ISO strings. What the index states in another form is left out: path (it is in
location), docs and docs_path (in docs), a chart’s dataframe (in inputs), and a
dataframe’s series (they are records of their own). A chart’s series, the list of what it
shows, is declared metadata and stays (so written 2026-10-15; before that the index dropped it
from every kind).
Timestamps are RFC 3339 with an explicit offset, in UTC.
generated_at is when the index last changed. A build that finds nothing different from the
index.json already there leaves the file as it is, timestamp included, so that a build which
changes nothing writes nothing and a published site does not churn. (Decided 2026-10-12.)
Absent versus empty. A field that was not declared is omitted. A field declared as empty is present and empty. A consumer can therefore tell “no tags were given” from “tags were explicitly cleared”, which matters for diffing.
Record kinds#
Catalog#
The root. Exactly one per index.
{
"kind": "Catalog", "version": "chartbook/v1",
"id": "ftsfr/catalog",
"name": "FTSFR Catalog",
"pipelines": ["ftsfr/fed_yield_curve", "ftsfr/crsp_treasury"],
"policy": {
"mode": "warn",
"required": { "dataframes": ["name", "description", "sources"] }
}
}
policy is the catalog’s resolved [policy], so the keys of required are the manifest’s:
project, dataframes, charts, notebooks. (An earlier version of this example wrote
dataframe.)
An index for a single pipeline omits the Catalog record and sets root to that pipeline’s ID.
(2026-10-15, built: the Catalog record also has a location, {"kind": "path", "value": "/…/catalog"}, as a pipeline’s has, so a writer knows where the catalog’s own template
overrides are; it is dropped from the published index altogether, since a catalog is only ever
a folder and a bare kind would say nothing. The diagnostics page is a
comparison a writer makes from the records and this record’s policy, as the inventory
proposed; the build still writes the same comparison as a CSV file.)
Pipeline#
{
"kind": "Pipeline", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve",
"name": "Fed Yield Curve",
"description": "Daily Treasury constant-maturity yields.",
"maintainer": "Jeremy Bejarano",
"contributors": ["…"],
"repo_url": "https://gitlab.internal/ftsfr/fed_yield_curve",
"os_compatibility": ["Linux", "Windows"],
"location": { "kind": "path", "value": "/Users/x/repos/fed_yield_curve" },
"scripts": { "build": "doit", "test": "pytest" },
"dependencies": [
{
"id": "ftsfr/crsp_compustat",
"via": "rev",
"location": { "kind": "git", "url": "https://gitlab.internal/…", "rev": "a1b2c3d" },
"pinned_hash": "sha256:…"
}
],
"assets": ["ftsfr/fed_yield_curve:treasury_rates",
"ftsfr/fed_yield_curve:chart/curve_today"],
"docs": { "path": "./README.md", "rendered": "content/ftsfr/fed_yield_curve/readme.md" }
}
location.kind is "path" or "git". dependencies[].via records whether the pin came from a
local directory or a fetched revision — the field that keeps “was this built against a stable
upstream?” answerable.
(2026-10-15, built: a pipeline that supplies pages for its own site — [project] site_dir, or
docs_src/site/ when that exists — has site_dir (path relative to its root, and
resolved_path until published) and site_pages, every Markdown file under it by relative path,
sorted. The build copies them beside the generated pages; a writer lists them in the front page’s
table of contents and reads index_toc.md if it is one of them. Without hashes for now.)
(Added 2026-10-12 with the implementation; proposed.) The example above shows a git
dependency. One that is a directory is written like this:
{ "id": "ftsfr/fed_yield_curve",
"via": "path",
"location": { "kind": "path", "path": "../fed_yield_curve",
"value": "/Users/x/repos/fed_yield_curve" } }
path is as declared in [dependencies], relative to the pipeline’s root. value is the
absolute directory, as in the pipeline’s own location, and like it is left out of a published
index. pinned_hash is there once the dependency is pinned, copied from chartbook.lock. A git location’s url is written
without any user name or password it had in the manifest (https://user:token@host/…), since a
published index goes wherever the site goes.
dependencies is absent when the manifest has no [dependencies] table, and [] when it has
an empty one, by the rule under “Absent versus empty”. (The worked example below writes
"dependencies": [] for a pipeline that is not said to have the table.)
A dependency’s id is its key in [dependencies], with one exception. A key is the pipeline’s
own name for another pipeline. In a catalog’s index, a dependency that is one of the catalog’s
pipelines is written by the catalog’s ID for it, so that it is the id of a Pipeline record
in the same file. The same goes for a model’s inputs.
Dataframe#
{
"kind": "Dataframe", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:treasury_rates",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "dataframe", "name": "treasury_rates" },
"pipeline": "ftsfr/fed_yield_curve",
"name": "Treasury Rates",
"description": "Constant-maturity yields from H.15.",
"location": { "kind": "file",
"path": "./_data/treasury_rates.parquet",
"resolved_path": "/Users/x/repos/fed_yield_curve/_data/treasury_rates.parquet" },
"content_hash": "sha256:…",
"size_bytes": 2148374,
"row_count": 15842,
"date_col": "date",
"columns": [
{ "name": "date", "dtype": "date" },
{ "name": "DGS10", "dtype": "float64" },
{ "name": "DGS2", "dtype": "float64" }
],
"inputs": ["ftsfr/crsp_compustat:ratings"],
"sources": ["FRED"],
"providers": ["Federal Reserve Board"],
"access_types": ["Public"],
"license": "Public domain",
"license_expiration": null,
"tags": ["Interest Rates", "Daily"],
"docs": { "path": "./docs_src/dataframes/treasury_rates.md",
"rendered": "content/ftsfr/fed_yield_curve/dataframes/treasury_rates.md" },
"built_by": "sha256:…"
}
columns carries the physical schema read from the parquet file. This is what makes which series
exist knowable without any declaration — see below. The schema is read the way data.load()
reads it, so it includes the partition columns of a hive-partitioned dataset, and a dtype is
the Polars type in lower case with its parameters in square brackets: float64, int32,
string, boolean, date, datetime[ns], datetime[us, UTC], duration[ms],
decimal[10,2], list[int64], categorical, struct. (The dtype vocabulary was added
2026-10-12 with the implementation; this document had only shown date and float64.) inputs carries the asset-level edges;
built_by points at the Build record whose output_hash matches.
Series#
A series needs no storage — the dataframe holds the data. It needs an address, so documentation and metadata can attach to one column.
{
"kind": "Series", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:treasury_rates#dgs10",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "dataframe", "name": "treasury_rates", "part": "dgs10" },
"dataframe": "ftsfr/fed_yield_curve:treasury_rates",
"column": "DGS10",
"declared": true,
"name": "10-Year Treasury Constant Maturity",
"units": "Percent",
"frequency": "Daily",
"docs": { "path": "./docs_src/series/dgs10.md", "rendered": "…" }
}
declared: false marks a series inferred from the parquet schema — present, addressable, and
undocumented. declared: true marks one with a [dataframes.x.series.y] table behind it. So
declaring is how you document a series, not how you make it exist, and a FRED-style listing works
from day one without anyone filling in metadata.
An explicit column is what lets the address survive a column rename.
A series is the part of a dataframe, in the reference grammar’s word: what follows #. For
an undeclared series the part is the column’s own name. A column whose name is not a legal part
(it contains a space, say) gets no Series record until a [dataframes.x.series.y] table gives
it a key; it still appears in the dataframe’s columns. (Decided 2026-10-12.)
That is deliberate, and comes with a recommendation the maintainer made when deciding it:
columns should have code-friendly, SQL-compatible names, letters, digits and underscores,
starting with a letter (spread_10y_2y). The readable name (“10Y-2Y Spread”) is metadata, and
belongs in the series table’s name, not in the data. Design: data access makes the same
names appear in SQL, which is one more reason.
The dataframe’s date_col is not a series. A declared series whose column the data does not
have is still recorded, and the build warns.
Chart#
{
"kind": "Chart", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:chart/curve_today",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "chart", "name": "curve_today" },
"pipeline": "ftsfr/fed_yield_curve",
"name": "Yield Curve, Latest",
"location": { "kind": "file", "path": "./_output/curve_today.html",
"resolved_path": "/Users/x/repos/fed_yield_curve/_output/curve_today.html" },
"content_hash": "sha256:…",
"inputs": ["ftsfr/fed_yield_curve:treasury_rates#dgs10",
"ftsfr/fed_yield_curve:treasury_rates#dgs2"],
"tags": ["Interest Rates"],
"frequency": "Daily",
"units": "Percent",
"date_cleared_by_iv_and_v": "2026-01-15",
"last_legal_clearance_date": "2026-01-15",
"docs": { "path": "…", "rendered": "…" }
}
inputs replaces the single dataframe field, as a list — a chart may plot several dataframes, and
with series now addressable it may name individual series. This is a breaking change to the manifest
and worth taking while nothing external depends on it.
Until the manifest changes, a chart’s dataframe = "x" becomes "inputs": ["<pipeline>:x"] in
the index. The entries of inputs are IDs, so they are always fully qualified, whatever was
written in the manifest.
Notebook and Note#
{ "kind": "Notebook", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:notebook/analysis",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "notebook", "name": "analysis" },
"pipeline": "ftsfr/fed_yield_curve",
"name": "Initial exploration",
"location": { "kind": "file", "path": "./_output/analysis.ipynb", "resolved_path": "…" },
"content_hash": "sha256:…",
"inputs": ["ftsfr/fed_yield_curve:treasury_rates"] }
{ "kind": "Note", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:note/methodology",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "note", "name": "methodology" },
"pipeline": "ftsfr/fed_yield_curve",
"location": { "kind": "file", "path": "./docs_src/methodology.md", "resolved_path": "…" },
"content_hash": "sha256:…",
"docs": { "path": "./docs_src/methodology.md", "rendered": "…" } }
A notebook’s name is inferred from its first level-one heading when not declared, as today.
Model#
A trained model that a pipeline declares. The record, its fields and what they are for are in
Design: models (“In the index”). It has the same skeleton as the other asset records — id,
ref, pipeline, name, location, content_hash, inputs, docs, built_by — plus
metrics, and files when the model is a directory. Its ID is
ftsfr/fed_yield_curve:model/rate_forecaster.
A model whose inputs include another pipeline’s dataframe also has pinned_inputs: for each
such input, its ID as inputs writes it, and the content hash chartbook.lock pins it at.
"inputs": ["ftsfr/fed_yield_curve:treasury_rates", "ftsfr/rate_forecast:features"],
"pinned_inputs": { "ftsfr/fed_yield_curve:treasury_rates": "sha256:4b1f…" }
It is absent when nothing is pinned. An input of the pipeline’s own is not in it: a local
asset is live, so there is no pin to name. It says what is pinned, which is what code that
asks for the input is handed; it is not proof of what one training run read. That is a
Build record’s job. (Decided by the maintainer 2026-10-12: a field beside inputs, which
stays a list of IDs with no version in them.)
(2026-10-13, proposed: a Model record also has aliases, the model’s labels as declared,
and versions, oldest first: each with version (its name), content_hash, made_at,
commit, and where there are any labels, metrics and inputs. They are the versions in
the store of the machine that built the index.)
(2026-10-14, built: when the model file’s bytes are a version whose snapshot recorded what they
were trained on, the record has trained_with, the content hash of each input then, keyed as the
lockfile writes an input (d, rates:treasury), and stale_inputs, the keys whose content now
differs. 2026-10-15: inputs_now beside them, each input’s content hash now under the same keys
— a local dataframe’s as it is in the folder, another pipeline’s as chartbook.lock pins it —
so that the record alone says which input moved from what to what. A writer cannot derive it:
inputs and pinned_inputs are keyed by build ID, and the index does not state which
lockfile-style key is which build ID once a declaration is reordered or two upstreams share a
dataframe name.)
Model records are written (step 2), without built_by. An entry of a model’s inputs that
is in another pipeline is written by the ID that pipeline has in this build when it is part of
it (a catalog knows rates as ftsfr/rates); otherwise by the name [dependencies] declares
that pipeline under (rates:x becomes ftsfr/rates:x when the table has "ftsfr/rates"); and
as the manifest states it when nothing knows the pipeline. So in a
catalog’s index an input is the id of another record in the same file whenever it can be. A model’s location has
"kind": "file" whether its path is a file or a directory; files is what tells the two
apart.
Build#
The derivation record: a complete description of how an asset was produced. One object serving as build plan, cache key and provenance record.
{
"kind": "Build", "version": "chartbook/v1",
"asset": "ftsfr/fed_yield_curve:treasury_rates",
"input_hash": "sha256:…",
"output_hash": "sha256:…",
"recipe_hash": "sha256:…",
"params": {},
"inputs": {
"ftsfr/crsp_compustat:ratings": {
"hash": "sha256:…",
"via": "rev",
"location": "https://gitlab.internal/ftsfr/crsp_compustat@a1b2c3d"
}
},
"code": { "rev": "a1b2c3d4e5f6…", "dirty": false },
"tools": { "chartbook": "0.2.0", "python": "3.12.4", "polars": "1.9.0" },
"built_at": "2026-10-11T21:58:09Z",
"duration_seconds": 412.6,
"impure": ["wrds_fetch"]
}
asset is the ID of what was built, so for a chart it is …:chart/curve_today and for a model
…:model/rate_forecaster.
code is the pipeline’s git commit when the asset was built, and whether the working tree had
uncommitted changes. It answers “what code made this?” in a form a person can check out. It is a
label for people, not an input to any hash: recipe_hash is what the cache uses. (Added
2026-10-12, proposed. It is what lets a model version say which code trained
it.)
input_hash is the store’s lookup key — computable before building, which is what lets a cache be
asked and the work skipped. output_hash is what downstream consumers compare, so a rebuild
producing identical bytes does not cascade.
Hashes are transitive. input_hash covers each input’s hash, not its path or name. Because
those hashes were computed the same way, an asset’s identity transitively covers its whole upstream
history — a Merkle DAG, the same structure that makes a git commit hash a statement about an entire
repository. That is what makes recorded lineage checkable rather than merely trusted.
impure names steps that cannot be reproduced from their inputs. Verifiability reaches back to the
last impure boundary, and a digest of what arrived is recorded there.
Added for the versioned catalog#
Added 2026-10-16 (Design: the versioned catalog). In a catalog’s index only, read from the store of the machine that built it, as a model’s versions are:
A Pipeline record’s
snapshots: every snapshot of it in the store, newest first, each withcommit,made_at,name(the version’s folder underversions/),built_by_run, andrendered, whether the catalog built that snapshot’s site or shows the page that says why.For a pipeline shown from the store alone (
location.kind: store), itsassetsare those its newest snapshot’s own index lists, andasset_namesgives each one’s name there as plain text. Added 2026-10-17.A Model record’s
trained_from: for each input of another pipeline it was trained on, keyed asinputsis, the snapshot of that pipeline it was trained on: the one the model’s pipeline was pinned to when the model’s bytes were first snapshotted, else the newest holding the trained bytes made no later than that.
In any index: a Pipeline record’s from_snapshot (commit, made_at) when its folder is a
version laid out from the store; and on a pipeline’s dependencies entry, key, the name the
pipeline knows the other by in [dependencies], when it is not the other’s ID in the build (the
name its lockfile, its snapshots and trained_with use).
Where the lockfile fits#
chartbook.lock is the subset of the index that a project commits: its own Build records and its
resolved dependencies. The index is a build product and need not be committed; the lockfile is
source, because it is what makes a rebuild reproducible and an upstream pin stable.
Both use the same record schemas. There is no second format.
(Built 2026-10-12, chartbook.lock the module. Corrected here as built; the shape was decided
by the maintainer, the details below are proposed.) What the lockfile holds is not quite the
paragraph above. There are no Build records yet. And the part that freezes anything is
missing from it: one record for each asset of each dependency, with its content_hash.
{
"schema": "chartbook/lock/v1",
"root": "ftsfr/rate_forecast",
"records": [
{ "kind": "Pipeline", "version": "chartbook/v1", "id": "ftsfr/rate_forecast",
"dependencies": [
{ "id": "ftsfr/fed_yield_curve", "via": "path",
"location": { "kind": "path", "path": "../yield_curve" },
"pinned_hash": "sha256:9ac2…" } ] },
{ "kind": "Dataframe", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:treasury_rates",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "dataframe", "name": "treasury_rates" },
"pipeline": "ftsfr/fed_yield_curve",
"location": { "kind": "file", "path": "./_data/treasury_rates.parquet" },
"content_hash": "sha256:4b1f…", "size_bytes": 2148374 }
]
}
The envelope is the index’s, without
generated_atand withoutgenerator, so that neither the clock nor a ChartBook upgrade changes a lockfile.An asset record is the index’s record for that asset, cut down to these fields. It fits the index’s JSON Schema for its kind as it is. One with a
content_hashis a pin; one without was declared by the other pipeline and not built when this was pinned.The
Pipelinerecord is this pipeline’s, cut down toidanddependencies. That is below what the index’s schema requires of aPipeline(name,location,assets), on purpose: those would change the lockfile whenever the manifest changed. The lockfile’s own envelope schema,schemas/chartbook/lock/v1.json, says what is there.IDs use the dependency’s key in
[dependencies], which is this pipeline’s name for it.pinned_hashis the SHA-256 of a listing with one line per pinned asset of that dependency,<hex of its content hash>, two spaces,<its ID>, newline, sorted by ID.A
locationof kindpathhaspathand novalue: a lockfile holds no absolute path.A
locationof kindgithasurl,revif the manifest gives one, andcommit: the full hash thatrevmeant when the dependency was pinned.revis what was asked for;commitis what was got. The index copiescommitonto the pipeline’s dependency once it is pinned. Theurlnever has a user name or a password in it. (Proposed 2026-10-12, withgitdependencies.)
Candidate additions, deliberately out of v1#
Diagnosticrecords carrying metadata-completeness results. Derived and recomputable, so the index does not need to carry them yet.A parquet form of the asset table, for catalogs large enough that a browser should query the index with DuckDB-WASM rather than parse JSON.
Per-pipeline index fragments (
_build/index/<scope>/<name>.json) to make incremental regeneration cheap. An implementation detail if adopted;index.jsonstays the normative artifact a consumer reads.An
Exportrecord describing what was emitted to RO-Crate, OpenLineage or Dagster, so a consumer can tell which views exist.
Worked example#
The smallest useful index — one pipeline, one dataframe, one documented series, one chart.
{
"schema": "chartbook/index/v1",
"generated_at": "2026-10-11T22:14:03Z",
"generator": { "name": "chartbook", "version": "0.2.0" },
"root": "ftsfr/fed_yield_curve",
"records": [
{ "kind": "Pipeline", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve",
"name": "Fed Yield Curve",
"location": { "kind": "path", "value": "/Users/x/repos/fed_yield_curve" },
"scripts": { "build": "doit" },
"dependencies": [],
"assets": ["ftsfr/fed_yield_curve:treasury_rates",
"ftsfr/fed_yield_curve:chart/curve_today"] },
{ "kind": "Dataframe", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:treasury_rates",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "dataframe", "name": "treasury_rates" },
"pipeline": "ftsfr/fed_yield_curve",
"name": "Treasury Rates",
"location": { "kind": "file", "path": "./_data/treasury_rates.parquet" },
"content_hash": "sha256:4b1f…",
"date_col": "date",
"columns": [ { "name": "date", "dtype": "date" },
{ "name": "DGS10", "dtype": "float64" } ],
"inputs": [],
"built_by": "sha256:9ac2…" },
{ "kind": "Series", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:treasury_rates#dgs10",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "dataframe", "name": "treasury_rates", "part": "dgs10" },
"dataframe": "ftsfr/fed_yield_curve:treasury_rates",
"column": "DGS10", "declared": true,
"name": "10-Year Treasury Constant Maturity",
"units": "Percent", "frequency": "Daily" },
{ "kind": "Chart", "version": "chartbook/v1",
"id": "ftsfr/fed_yield_curve:chart/curve_today",
"ref": { "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "chart", "name": "curve_today" },
"pipeline": "ftsfr/fed_yield_curve",
"name": "Yield Curve, Latest",
"location": { "kind": "file", "path": "./_output/curve_today.html" },
"content_hash": "sha256:cc71…",
"inputs": ["ftsfr/fed_yield_curve:treasury_rates#dgs10"] },
{ "kind": "Build", "version": "chartbook/v1",
"asset": "ftsfr/fed_yield_curve:treasury_rates",
"input_hash": "sha256:0e55…",
"output_hash": "sha256:4b1f…",
"recipe_hash": "sha256:7721…",
"inputs": {},
"tools": { "chartbook": "0.2.0", "python": "3.12.4" },
"built_at": "2026-10-11T21:58:09Z",
"impure": ["fred_api_fetch"] }
]
}
The asset records here leave resolved_path out of location, as a published copy of the index
does. Note the Series record for DGS10 but none for date, and that a hypothetical
DGS2 column would appear as declared: false — addressable, undocumented. Note also that the chart’s inputs
names a series, so “what does this chart depend on” is answerable at column granularity even
though the build graph remains coarse.
What this unblocks#
Once the index exists, several things stop being subsystems and become readers of one file:
An MCP server exposing pipelines and assets as resources, and
data.loadas a tool.A Dagster export, mapping each asset to an
@assetwithdeps.Faceted search over the catalog, computed in a browser against a small file.
A provenance panel on every asset page: what built this, from which inputs at which hashes. (Built 2026-10-14:
chartbook/provenance.py, the index is built first and each page reads its record; Design: start here, “6b, as built”.)A build diff for reviewing what changed, including changes made by automated tooling. (Built 2026-10-14:
chartbook index diff,chartbook/indexdiff.py; Design: start here, “6a, as built”.)
Which is the reason this document comes first.
What the first implementation does#
Built in chartbook/index.py and chartbook/hashing.py, with the JSON Schemas beside them in
chartbook/schemas/. tests/test_index.py and tests/test_hashing.py are the executable form
of this section.
(2026-10-14, for the writer, Design: the writer, and what every page shows: every write-up carries its content_hash; a file
location its modified_at; a dated dataframe its date_range; a pipeline its code_changed_at.)
What is written. Catalog, Pipeline, Dataframe, Series, Chart, Notebook, Note
and Model records; dependencies on a Pipeline that declares some, each with its
pinned_hash once pinned; and pinned_inputs on a Model. Not yet: Build
records and built_by;
inputs on a dataframe or a notebook (the manifest has no field to declare them); rendered
in docs. A dependency that is a directory, once pinned from a snapshot, has the
snapshot’s commit in its location.commit and its time in snapshot (2026-10-13, proposed;
a git dependency already had commit). A pipeline’s scripts are written as "scripts": { "build": …, "test": … },
sorted by name: all of [scripts], with [project] build as build (all of them since
2026-10-13; before the table was read, only build).
Where it is written. The _build/ layout at the top of this document is a destination, not
today’s layout. chartbook build (since 2026-10-13, chartbook docs: the command that
renders the site was renamed) writes two files:
the published index,
index.jsonbeside the HTML (docs/index.json), so that it travels with the site and a browser can fetch it. It has noresolved_pathanywhere, and a pipeline’slocationof kindpathhas novalue, nor does a dependency’s: local absolute paths never reach a published site;the full index,
index.jsonin the build directory (_docs/), with those paths. A plain build deletes that directory when it finishes, so the full index outlives the build only with--keep-build-dirsor in an incremental build, which is the default for a catalog.
chartbook.index.build_index(manifest) returns the full index to a program, and
chartbook.index.published() turns it into the other.
Records come from one object. chartbook.refs.AssetRef gives the id (str(ref)) and the
ref (ref.to_dict()). A local name from a manifest becomes an ID with
ref.in_pipeline(pipeline_id). In a catalog, the pipeline’s ID is the catalog’s key for it.
It is deterministic. The same manifests and files give the same bytes, apart from
generated_at. Records are in a fixed order: the catalog; then pipelines by ID; within one, the
pipeline, then its dataframes, charts, notebooks, notes and models, each by name, and each
dataframe followed by its series in column order. Declared metadata is in sorted key order, so reordering
a chartbook.toml does not reorder the index.
Hashes are cached. chartbook.hashing.HashCache remembers each file’s hash against its size
and modification time, in .chartbook-hash-cache.json in the build directory. An incremental
build reads only the files that changed. A plain build starts from an empty build directory and
hashes everything, as it does everything else.
Files that are not built yet. A declared asset whose file is missing still has a record,
without content_hash, size_bytes, row_count or columns. The index describes what the
manifest declares; chartbook check is what complains about missing files. (By default
chartbook docs stops on a missing file before it gets this far, and with --no-strict the
affected pipelines are left out of the site and of the index alike. The case arises through the
Python API, and in a pipeline that has built none of its outputs yet, which chartbook docs
renders with each of them shown as not built.)
A key that is not an address. An asset whose key in chartbook.toml cannot be written in a
reference (it contains a space, say) is left out, with a warning naming it.
The site does not read the index. Sphinx still renders from the manifests. Moving the renderer onto the index is its own change (step 6 in Design: start here), for when the index has been stable for a while.
Decided while building it#
Each of these was open or unstated when the build began.
Decided by the maintainer, 2026-10-12:
The hashing rule for a glob or a directory, as written under “Conventions”. Which files count was decided a second time the same day, when the store was built: a short fixed list of operating-system files is left out.
Columns whose names are not legal parts have no
Seriesrecord until a table names them. Columns should have SQL-compatible names; the readable name is metadata.generated_atis when the index last changed; an unchanged index is not rewritten.
Proposed: implemented, and waiting on the maintainer’s yes:
Two files, with the published one free of local paths.
The
dtypevocabulary.docs.textfor inline write-ups, and norenderedfor now.An asset record carries all declared metadata, not a fixed list of fields.
policy.requiredkeeps the manifest’s plural keys.Pipelines that a
--no-strictbuild skips are left out of the index.
Proposed with declared dependencies (step 3a), 2026-10-12: implemented, and waiting on the maintainer’s yes:
A
pathdependency’slocationhaspath(as declared) andvalue(absolute, not published). The design showed only thegitform.dependenciesis absent when there is no[dependencies]table, not[]. A build of a project with no dependencies therefore writes the same index as before.Within one index, a dependency and a cross-pipeline input are written by the ID the pipeline has in that index, when it is one of its pipelines; by its key in
[dependencies]otherwise.Credentials are removed from a
gitdependency’surl.
Proposed with cross-pipeline references (step 3d), 2026-10-12:
pinned_inputsholds only inputs that belong to another pipeline, and is absent when none is pinned. (That it is a field besideinputswas the maintainer’s decision.)A
chartbook.lockthat cannot be read leaves what is pinned out of the index, with a warning, and does not stop the build.
Proposed with git dependencies (step 3e), 2026-10-12:
A dependency’s
locationof kindgitgainscommit, besideurlandrev.