Design: references#
Note
A design document, and the specification for how ChartBook addresses an asset. Every other design here writes addresses in this grammar, so read it before Design: the asset graph, Design: the index, Design: models or Design: data access. New to the project? Start with Design: start here.
Status: accepted 2026-10-12. The parser accepts the whole grammar (chartbook.refs, in
0.2.0). A local reference resolves to the path its manifest declares, and a reference to an
asset of another pipeline resolves to the copy that chartbook.lock pins, for all five
kinds. A part, a version and a registry parse, and are refused as “reserved and not yet
supported”. See What works today.
The idea in one picture#
Data is the spine of ChartBook. An organization has pipelines, a pipeline has dataframes, and a dataframe has series (its columns):
ftsfr / fed_yield_curve : treasury_rates # dgs10
scope pipeline dataframe series
Everything else a pipeline declares hangs off the pipeline, and says what kind of thing it is:
ftsfr/fed_yield_curve:chart/curve_today
ftsfr/fed_yield_curve:notebook/analysis
ftsfr/fed_yield_curve:note/methodology
ftsfr/fed_yield_curve:model/rate_forecaster@v3
That string is a reference. It is the one way to name an asset, wherever a name is needed: on
the command line, in Python, in chartbook.toml, in the index, in a lockfile and in a URL.
Examples#
Reference |
Means |
|---|---|
|
The dataframe |
|
The chart |
|
A notebook |
|
A note |
|
A model |
|
The series |
|
One file inside that model |
|
Version 3 of that model |
|
Whichever version is currently labelled |
|
A dataframe in another pipeline |
|
The same, naming the organization too |
|
A chart in another pipeline |
|
The same dataframe, from a named registry |
The grammar#
reference = [ pipeline-id ":" ] asset [ "#" part ] [ "@" rev ]
pipeline-id = [ registry "/" ] [ scope "/" ] pipeline
asset = [ kind "/" ] name
kind = "dataframe" | "chart" | "notebook" | "note" | "model"
scope, pipeline, name = one or more of A-Z a-z 0-9 . _ -
part = one or more of A-Z a-z 0-9 . _ - /
rev = one or more of A-Z a-z 0-9 . _ - :
registry = a host name, optionally with a port
There are four separators, and each asks a different question. Read left to right:
Separator |
Question it answers |
Example |
|---|---|---|
|
One level down. Scope, then pipeline. Kind, then name. |
|
|
“From this pipeline, take…” |
|
|
“…this part of it.” |
|
|
“…at this version.” |
|
Because the separators differ, any piece can be left out and the rest still reads the same way. That is the whole reason there are four and not one.
Rules#
No colon means local. A reference without
:names an asset of the pipeline you are in. The current pipeline is found by walking up from the caller to the nearestchartbook.toml, as git finds a repository.No kind means dataframe.
treasury_ratesanddataframe/treasury_ratesare the same reference. The short one is canonical: ChartBook always writes it that way.Kinds are singular.
chart/gdp, nevercharts/gdp. The manifest’s tables are plural ([charts.gdp]); the parser says “did you meanchart?” when given a plural.Without a colon,
x/yis alwayskind/name. It is neverscope/pipeline. Softsfr/fed_yield_curveis not an asset reference, and the error says to writeftsfr/fed_yield_curve:<asset>. This keeps the short forms unambiguous.Each kind has its own names.
[dataframes.gdp]and[charts.gdp]are two assets,gdpandchart/gdp. Nothing is ambiguous, because the kind is part of the address.An asset may be named like a kind.
chartalone is a dataframe calledchart.A registry is only ever the first of exactly three components, and only if it looks like a host: it contains
.or:, or islocalhost. Socharthub.example/acme/rateshas a registry, andmy.group/ratesis a scope with a dot in it. No ID that was valid before registries were reserved changes meaning. (This is Docker’s rule, narrowed to three components.)Parsing goes right to left: take off
@revat the last@, then#partat the first#, then split pipeline from asset at the last:. That order is what lets a revision be a hash (@sha256:7f3a…) and a registry carry a port (localhost:5000/acme/rates:gdp).
A reference to a pipeline is a different thing#
Some places name a pipeline, not an asset: data.load(pipeline="ftsfr/fed_yield_curve", …),
a key of [dependencies], a key of a catalog’s [pipelines]. Those take a pipeline ID,
[registry/][scope/]name, optionally with @rev. They are handled by chartbook.identity, and
they also accept a repository URL. The pipeline-id half of an asset reference follows the same
rules, minus the URL form.
(Corrected 2026-10-12, proposed.) That is true of a lookup, such as data.load(pipeline=…).
A key of [dependencies] or of [pipelines] is narrower: a plain ID, name or
scope/name. A URL is never an identity, so it is not a key; and a dependency’s revision is
written in its entry (rev = "a1b2c3d"), so @rev in a key is refused as reserved.
Where a pipeline is found, given its name, is one function: chartbook.resolve.locate_pipeline.
It asks the [dependencies] of the pipeline that wants to know, then the catalog. A scoped name
must match exactly. A bare name matches if exactly one entry has it, so with
"ftsfr/fed_yield_curve" declared, fed_yield_curve:treasury_rates names that pipeline.
Kinds#
Kind |
Manifest table |
Index record |
What |
Python front door |
|---|---|---|---|---|
|
|
|
A series: one column |
|
|
|
|
Nothing yet |
|
|
|
|
Nothing yet |
|
|
|
|
Nothing yet |
|
|
|
|
A file inside the model (not yet) |
|
Why dataframe is the default. Data is what gets passed between pipelines, loaded in notebooks and written in dependency lists. Charts, notebooks and notes are mostly looked at, not passed around. The common case gets the short spelling. (kubectl and Hugging Face do the same: the most common kind needs no prefix.)
Why names are per kind, not one namespace. An earlier draft made every asset name unique
within a pipeline, so that scope/pipeline:name was enough. The maintainer rejected it: a chart
of GDP and the GDP dataframe are naturally both called gdp, charts are not first-class in the
way data is, and one namespace would have forced renames on existing projects.
Adding a kind means, in this order: add it to KINDS and KIND_TABLES in
chartbook/refs.py; teach the manifest loader its table; add it to RESOLVABLE_KINDS in
chartbook/resolve.py; add its record to the index schema; give it a
page in the site; decide what #part means for it; and, if code will ask for it often, give it a
front door module. model was the first kind to go through this; Design: models lists every
place it touched, under “What is built”.
One address, three spellings#
The same address is written three ways, depending on who is reading it.
Text, for people, the command line, and chartbook.toml:
$ chartbook path chart/curve_today
$ chartbook path curve_today --kind chart
Python, where the kind can be an argument:
from chartbook import assets, data
assets.path("chart/curve_today")
assets.path("curve_today", kind="chart") # the same call
data.path("treasury_rates") # dataframes only
JSON, in the index and anywhere a program reads an address. Named fields, nothing to parse:
{ "scope": "ftsfr", "pipeline": "fed_yield_curve",
"kind": "model", "name": "rate_forecaster", "rev": "v3" }
kind and name are always present. The other keys (registry, scope, pipeline, part,
rev) appear only when set, in the order the address is written.
What kind= means#
In Python and on the command line, kind says what you expect:
the reference names no kind → it takes this one;
the reference names the same kind → fine;
the reference names a different kind →
KindMismatchError.
assets.path("gdp", kind="chart") # chart/gdp
assets.path("chart/gdp", kind="chart") # chart/gdp
assets.path("chart/gdp", kind="note") # KindMismatchError
assets.path("gdp") # the dataframe gdp
Type safety, the small version#
Two things, and no more:
Parse once.
chartbook.refs.parse_asset_ref()turns text into anAssetRef, a frozen object with named fields that is checked when it is made. Code inside ChartBook passes that object around, never a raw string, and never splits a reference by hand.Typed front doors.
chartbook.assetstakes any kind.chartbook.datatakes dataframes only:data.path("chart/gdp")raises, and says to usechartbook.assets. A laterchartbook.modelsdoes the same for models. So a typo cannot hand back a chart’s HTML file where a parquet file was meant.
from chartbook import assets
ref = assets.parse("ftsfr/fed_yield_curve:model/rate_forecaster@v3")
ref.scope, ref.pipeline, ref.kind, ref.name, ref.rev
# ('ftsfr', 'fed_yield_curve', 'model', 'rate_forecaster', 'v3')
ref.pipeline_id # 'ftsfr/fed_yield_curve'
ref.local_name # 'model/rate_forecaster'
ref.table # 'models' (the manifest table)
ref.is_local # False
str(ref) # the canonical text
ref.to_dict() # the JSON form
assets.AssetRef.from_dict(ref.to_dict()) == ref # True
Parts: ##
A part is something inside an asset that deserves its own address. It needs no storage of its own; the asset already holds it.
(2026-10-13, decided by the maintainer: a part is an address, and never a way to load
data. data.load("treasury_rates#dgs10") will not be built: ChartBook does not select
columns, and a column is picked with polars or SQL. Design: start here, step 4, “4e”. What
follows describes the address.)
In a dataframe, a part is a series.
treasury_rates#dgs10is one column, so that documentation, units and a frequency can attach to it. The key after#is the series’ key in[dataframes.treasury_rates.series.dgs10], which may name a differently-spelled column. See Addressing a series inside a dataframe in the asset graph.In a model, a part is a file. A model is often a directory (weights, a tokenizer, a config).
model/rate_forecaster#weights/best.ptis one file in it. This is why a part may contain/.Charts, notebooks and notes have no parts yet. The syntax parses; nothing gives it meaning.
# was chosen because it maps onto the URL fragment of the asset’s page
(…/treasury_rates.html#dgs10), and because it cannot appear in a name. In a shell it sometimes
needs quoting (zsh with extended globbing reads # specially).
Versions: @#
@ selects a version. Two kinds of version name exist, and both are needed:
an exact version, which never changes meaning:
@v3, or a content hash@sha256:7f3a…;a label that moves:
@championpoints at whichever version was last given that label.
This is Docker’s lesson. A tag (:latest) and a digest (@sha256:…) are different things, and
conflating them is how “it worked yesterday” happens.
Proposed, not yet settled
What follows is the working proposal for how a rev is looked up. It needs the store and the
lockfile, so it is settled when those are built (step 4 in the Design: start here). The decided
part is only: @ means a version, and exact versions and moving labels both exist.
If it contains
:, it is a content hash (sha256:<hex>): exactly those bytes.Otherwise, if the asset’s pipeline has recorded a version or an alias by that name for that asset (
v3,champion), it is that.Otherwise it is a git revision of the pipeline (a commit, tag or branch): the asset as that revision of the pipeline built it.
Where version names and aliases are recorded is proposed in Design: models, since models are the first asset that needs them.
Where references appear#
Place |
Example |
Notes |
|---|---|---|
Command line |
|
|
Python |
|
Text or an |
|
|
A local dataframe reference today. Future fields ( |
The index |
|
IDs are fully qualified and carry no |
The lockfile |
Keys of pinned assets |
Same form as index IDs |
SQL templates |
|
Dataframes only. See Design: data access |
URLs |
|
Why |
What works today#
Form |
Example |
Today |
|---|---|---|
Local dataframe |
|
Resolves |
Local chart, notebook, note |
|
Resolves |
A dataframe through a catalog |
|
Resolves, by the older two-argument call |
Another pipeline’s asset |
|
Resolves, to the pinned copy in the store, for a pipeline in |
A part |
|
Parses. |
A version |
|
Parses. |
A registry |
|
Parses. |
Local model |
|
Resolves |
Local is live; another pipeline’s is pinned. treasury_rates is a file of the pipeline
you are in, which you are editing, so it resolves to that file. fed_yield_curve:treasury_rates
is someone else’s, so it resolves to the copy your chartbook.lock pins, in the store, and
does not change until chartbook update. The other pipeline must be in [dependencies]; one
that is not raises DependencyNotDeclaredError, and an asset with no pinned copy raises
AssetNotPinnedError. Both say what to add or run. (Design: the asset graph, “Pinning and
isolation”.)
All three Unsupported… errors are ValueErrors whose message contains “reserved syntax and not
yet supported”. A reference that breaks the grammar raises a plain ValueError instead. The
difference matters: “not yet” tells the user the syntax is right.
What unlocks each row is in the Design: start here’s build order.
Where the code is#
Module |
What it does |
|---|---|
|
The grammar. |
|
The one resolver. |
|
The general front door: |
|
The dataframe front door: |
|
The model front door: |
|
Pipeline IDs: |
|
Writes each asset’s fully qualified |
|
|
|
Every example in this document, as a test |
Rules for whoever extends this:
There is one parser. Do not split a reference with string operations anywhere else. If a new form is needed, add it to
refs.pyand to this document in the same change.There is one resolver.
path(),load()and the CLI all callresolve(). Name resolution is singular by design (Not doing): two code paths that disagree about a name make dependencies unportable.Refuse, do not guess. A form that cannot be resolved yet raises an
Unsupported…error. It never falls back to something that happens to work.To unlock a form, remove its refusal in
parse_declared_ref()and add the resolution next to_resolve_local()and_resolve_pinned(). The grammar does not change. (There used to be a second function,parse_local_ref(), for what resolved, while another pipeline’s asset could be stated and not resolved. Since 2026-10-12 what may be stated is what resolves.)
Why this shape#
What other tools teach#
Tool |
Address |
Lesson taken |
|---|---|---|
Docker |
|
Different separators let you leave parts out. A moving label and an exact version are different things; keep both |
Bazel, Python entry points, git |
|
A colon means “container, then the thing inside it”. That is our colon |
URLs |
|
|
kubectl, Hugging Face |
|
Kind goes in front, and the most common kind needs no prefix |
npm, pip, Hugging Face |
|
|
SQL, Maven |
|
With one separator you must fill every slot, or depend on a hidden “current schema” setting |
Alternatives that were turned down#
One separator for everything (
ftsfr.fed_yield_curve.treasury_rates.dgs10). You could not tellpipeline.dataframefromdataframe.series, and.is legal inside a name.The kind as a Python argument only (
data.path("gdp", kind="chart")). It works in Python and nowhere else: not on a command line, not in TOML, not in the index. And it madechartbook.datahand out charts. The argument stays, as a statement of what you expect.One namespace for all asset names. See “Kinds” above.
A URI scheme (
chartbook://ftsfr/fed_yield_curve/dataframe/treasury_rates). Unambiguous, and nobody would type it. The short local form is the one people write a hundred times a day.Plural kinds (
charts/gdp), to match the manifest tables. Singular reads as a name (“the chart gdp”) and matches the index’s record kinds.
Open questions#
How
@revis looked up. Proposed above; settled with the store. (2026-10-13: built for one case, a version of one of this pipeline’s own models.model/x@7f3a9c2e41b0is found among the pipeline’s snapshots by the start of the model’s content hash, andmodel/x@championthrough a label in[models.x.aliases]. A revision of any other kind of asset, or of another pipeline’s, is still refused. Design: start here, “4d, as built”.)Parts of charts, notebooks and notes. A notebook cell? A chart’s series? Nothing has asked for one yet.
Whether
chartbook.tomltables may themselves be keyed by a reference (for example documenting a series of another pipeline’s dataframe). Not needed so far.