Design: chartbook.toml Format v2#

Status

Accepted — designed 2026-07-20. This document is both the specification for the v2 format and the permanent record of why it looks the way it does. Implementation status is tracked in the Implementation plan section at the bottom.

Summary#

Format v2 collapses the three v1 metadata sections — [config], [site], and [pipeline] — into a single [project] table, makes every field optional with sensible defaults, removes chartbook_format_version, infers the project type from file structure, introduces scoped pipeline identities (scope/name), and moves required-field policy out of the format and into the catalog. The [charts], [dataframes], [notebooks], and [notes] sections keep their structure but their field names are de-prefixed in the same break (see Entity sections) so files are migrated once.

v1 files are not supported by the v2 loader. A one-shot migration script rewrites them; see Migration.

Motivation#

The v1 format split project metadata across three sections. A survey of every chartbook.toml in the wild (19 ftsfr pipeline repos, their catalog, the cookiecutter template, and the bundled examples) showed the split never carried its weight:

  • [config] held exactly two keys (type, chartbook_format_version) in every file ever written. A two-key section named “config” inside a configuration file is pure ceremony.

  • [site] and [pipeline] overlapped in practice. In all 19 ftsfr repos, site.title was byte-identical to pipeline.pipeline_name, and site.author named the same person as pipeline.lead_pipeline_developer. logo_path and favicon_path were empty strings in 18 of 19 files.

  • The split hid field-name drift. Because unknown keys were never validated, [pipeline] existed in four incompatible dialects simultaneously: the code expected os_compatibility/build_commands, all 19 ftsfr repos wrote runs_on, and the docs and cookiecutter template used runs_on_grid_or_windows_or_other/software_modules_command. The repos’ metadata was silently dead — never rendered — while diagnostics reported the same information as missing.

  • chartbook_format_version never worked. The check compared minor versions, a no-op while everything is 0.0.x — files stamped 0.0.2 passed against package 0.0.21 — and it would have rejected every existing file the day the package released 0.1.0.

  • Two competing identity systems. pipeline.id was used by standalone builds, while catalog add generated the catalog key from the directory name and ignored pipeline.id entirely. data.load(pipeline=...) used the catalog key.

  • A stray top-level key. webpage_URL was read from the manifest root, outside any section.

Design principles#

  1. One table for project metadata; sections only for collections. Sections earn their existence by holding many entries ([charts.*], [dataframes.*]), not by categorizing a handful of scalars.

  2. Everything optional, with sensible defaults. The format defines what fields mean; it does not decide what is required. Requiredness is policy, and policy belongs to the catalog that aggregates your work (see Catalog policy). The minimal valid pipeline chartbook.toml is an empty file — its presence alone marks a directory as a chartbook pipeline.

  3. Infer what can be inferred; error loudly on ambiguity. Project type and pipeline identity are derivable from structure (the [pipelines] table, the git remote, the directory name). When signals conflict, chartbook raises an error asking the user to be explicit rather than guessing.

  4. Identity is not location. Pipelines are identified by scoped names, not URLs. URLs are accepted as input and normalized. See Rationale for why.

  5. Batch format breaks. ChartBook is currently a personal project with no backward-compatibility obligations. Rather than maintain a dual-format loader, v2 makes all pending breaking changes at once and migrates every known file with a one-shot script.

Specification#

The [project] table#

All keys are optional. Types and defaults:

Key

Type

Default

Replaces (v1)

type

string: "pipeline" or "catalog"

inferred, see Type inference

config.type

id

string: scope/name or bare name

derived, see Identity

pipeline.id

name

string

directory name

site.title and pipeline.pipeline_name

description

string

""

pipeline.pipeline_description

maintainer

string

""

pipeline.lead_pipeline_developer and site.author

contributors

array of strings

[]

pipeline.contributors

repo_url

string

git remote origin if available, else ""

pipeline.git_repo_URL

site_url

string

file:// path to local built docs

top-level webpage_URL

readme

string (path)

"./README.md"

pipeline.README_file_path

copyright

string

current year

site.copyright

logo

string (path)

bundled default asset

site.logo_path

favicon

string (path)

bundled default asset

site.favicon_path

os_compatibility

array of strings

[]

pipeline.os_compatibility, runs_on, runs_on_grid_or_windows_or_other

build

string (multi-line allowed)

""

pipeline.build_commands, software_modules_command

site_dir

string (path)

"./docs_src/site/" when that directory exists, else disabled

pipeline.site_dir

enable_data_download

bool

false

site.enable_data_download

Notes:

  • There is no separate author key. The Sphinx author renders from maintainer (falling back to the first entry of contributors).

  • name doubles as the site title and the human-readable pipeline name; the v1 survey showed these were always the same value.

  • Unknown keys in [project] produce a warning naming the key and the closest known key. This is what prevents the runs_on-style dialect drift from recurring.

A fully populated pipeline file:

[project]
name = "CRSP Treasury"
description = "Treasury Securities Data from CRSP"
maintainer = "Jeremy Bejarano"
contributors = ["Jeremy Bejarano"]
repo_url = "https://github.com/ftsfr/crsp_treasury"
os_compatibility = ["Windows", "Linux", "macOS"]
build = """
doit
"""

[charts.treasury_returns_replication]
name = "Treasury Bond Portfolio Returns"
description = "Treasury bond portfolio returns from CRSP data"
dataframe = "ftsfr_treas_bond_portfolio_returns"
tags = ["treasury", "bonds", "returns"]
path = "./_output/treasury_returns_replication.html"
docs = """
## Treasury Bond Portfolio Returns
Monthly Treasury bond returns from CRSP, grouped by maturity bucket.
"""

[dataframes.ftsfr_treas_bond_portfolio_returns]
name = "Treasury Bond Portfolio Returns"
description = "Treasury bond portfolio returns grouped by maturity buckets"
sources = ["CRSP Treasury Database"]
providers = ["WRDS"]
tags = ["treasury", "bonds", "returns"]
path = "./_data/ftsfr_treas_bond_portfolio_returns.parquet"
date_col = "ds"
docs_path = "./docs_src/dataframes/ftsfr_treas_bond_portfolio_returns.md"

A minimal one — this is a complete, valid pipeline manifest:

[project]
name = "CRSP Treasury"

as is an empty file.

Project type inference#

Once [pipeline] merges into [project], the only structural signal left is the [pipelines] registry table, which makes inference unambiguous:

type key

[pipelines] present

pipeline-only sections present¹

Result

absent

no

any

pipeline

absent

yes

no

catalog

absent

yes

yes

error — ambiguous, set type explicitly

"pipeline"

yes

error — contradiction

"catalog"

yes

error — contradiction

explicit, consistent

as stated

¹ [charts], [dataframes], [notebooks], [notes] (non-empty).

The type is resolved once, at load time in load_manifest(), and stamped into the internal manifest. All consumers (catalog add validation, chartbook config, the docs builder) read the resolved type; nothing re-inspects the raw file.

Pipeline identity and namespacing#

Canonical identity is a scoped name: scope/name, e.g. ftsfr/crsp_treasury. Each component matches [A-Za-z0-9._-]+; lowercase is recommended. The scope is by convention the hosting org/user (GitHub org), but it is just a namespace string — nothing enforces the convention. A bare unscoped name is also legal for local-only projects.

Derivation precedence (highest first):

  1. Explicit id in [project].

  2. Derived: scope from the repo’s git origin remote (or repo_url), name from the directory name.

  3. Bare fallback: the directory name, unscoped.

In practice this means all existing ftsfr repos get correct scoped IDs with zero configuration.

Resolution for data.load(pipeline=...), chartbook ls, and friends, from most convenient to most explicit:

data.load(pipeline="crsp_treasury", ...)                          # bare name
data.load(pipeline="ftsfr/crsp_treasury", ...)                    # canonical
data.load(pipeline="https://github.com/ftsfr/crsp_treasury", ...) # URL, normalized
  • A scoped name matches its catalog key exactly.

  • A bare name matches any catalog entry whose name component equals it. If exactly one entry matches, it resolves; if several do, chartbook errors and lists the candidates (ftsfr/crsp_treasury, other/crsp_treasury) so the caller can qualify. Bare lookup keeps today’s ergonomics — you never type a scope you don’t need.

  • A URL is normalized to a scoped name: strip scheme and host, strip a trailing .git, take the last two path segments. URLs are never stored as identity.

Version pinning syntax is reserved, not implemented: scope/name@rev (e.g. ftsfr/crsp_treasury@a1b2c3d). The parser recognizes and rejects it with a “not yet supported” error, so the grammar is claimed before the dependencies feature needs it.

Catalog registry: [pipelines]#

Catalog keys are canonical pipeline IDs. TOML requires quoting keys containing /:

[project]
name = "FTSFR Catalog"

[pipelines."ftsfr/crsp_treasury"]
path = "../crsp_treasury"

[pipelines."ftsfr/fed_yield_curve"]
path = "../fed_yield_curve"
disabled = true                    # temporarily excluded from builds

[pipelines."finm33200/news_headlines"]
path = { unix = "/Users/jbejarano/GitRepositories/finm33200/news_headlines", windows = "T:/finm33200/news_headlines" }

Entry keys: path (string, or a { unix = ..., windows = ... } table for platform-specific locations — renamed from v1 path_to_pipeline with capitalized Unix/Windows keys) and disabled (bool, default false). A plain string value is shorthand for { path = ... }:

[pipelines]
"ftsfr/crsp_treasury" = "../crsp_treasury"

chartbook catalog add <dir> reads the target’s git remote and writes the scoped key automatically, falling back to the bare directory name.

Catalog auto-discovery: members#

(Addendum, 2026-07-21.) For a local catalog, membership can be declared as path patterns instead of one entry per pipeline, so new projects join automatically. Publishing to a shared/public catalog remains an explicit act — this mechanism deliberately targets the personal catalog, which is why implicit membership and filesystem-dependent loading are features here rather than liabilities.

[pipelines]
members = [
    "../GitRepositories/ftsfr_repos/*",
    "../GitRepositories/finm33200/news_headlines",
]
exclude = ["../GitRepositories/ftsfr_repos/scratch_*"]
disabled = ["ftsfr/sovereign_bonds"]

members, exclude, and disabled are reserved keys inside [pipelines] (a pipeline cannot use them as a bare ID); explicit entries coexist freely with them. Semantics:

  • members — path patterns, glob or literal, resolved relative to the catalog directory. Each matched directory holding a v2 pipeline chartbook.toml joins the catalog under its derived scoped ID (the pipeline’s own explicit id wins, then git remote + dirname). A plain path is just a glob that matches one thing.

  • Glob matches are forgiving: directories without a chartbook.toml and catalog-type manifests are silently skipped (dropping a stray checkout next to your pipelines must not break the catalog). The catalog never discovers itself.

  • Literal (non-glob) member paths are strict: a missing directory, missing manifest, or catalog-type target is a hard error — you named it, so it being broken is news.

  • v1-format members are always a hard error, glob or not, with the migration command in the message — silently skipping a real pipeline would be a mystery; erroring with the fix is not.

  • ID collisions are hard errors: two members deriving the same ID, or a member colliding with an explicit entry that points elsewhere, abort the load listing both paths and the fixes (set an explicit [project] id in one repo, or exclude one path). An explicit entry pointing at the same directory a member matched simply wins (that’s the rename/override mechanism).

  • disabled — a list of pipeline IDs, complementing the per-entry disabled = true flag; member-discovered pipelines have no entry to flag, so the list is how they’re switched off. Unknown IDs warn with a did-you-mean suggestion. chartbook catalog disable/enable edits the flag for explicit entries and the list for member-discovered ones.

  • exclude — path patterns removed from member matching before any of the above; excluding a nonexistent path is harmless.

  • All such errors are formatted with a “To fix, do one of:” section listing concrete commands/edits, and the CLI surfaces them as clean messages, not tracebacks.

chartbook catalog add on a path already covered by a members pattern reports the coverage instead of writing a redundant explicit entry.

Catalog policy#

The format is permissive; requiredness is catalog policy. A catalog may declare which fields its member pipelines must fill in:

[policy]
mode = "warn"          # "warn" (default): report in diagnostics page
                       # "strict": fail the catalog build

[policy.required]
project    = ["description", "maintainer", "repo_url"]
dataframes = ["date_col", "pull_method"]
charts     = ["units", "frequency"]
  • With no [policy] section, the hardcoded recommended-field lists that previously lived in diagnostics.py serve as the default warn-only policy, so the diagnostics page keeps working as before.

  • Policy is enforced only when the catalog builds. A pipeline built standalone is always permissive.

  • This separates schema (what fields mean — owned by chartbook) from policy (what is required — owned by whoever aggregates your work), the same split linters use.

Build commands#

build is a single string with shell-script semantics: a multi-line value is one script, so state carries across lines —

[project]
build = """
module load anaconda3/2024.02
conda activate ftsfr
doit
"""

This is deliberately not an array of independent commands: conda activate on line 1 must affect line 3, and an array invites the wrong mental model of each element running in a fresh shell. Today the field is documentation-only (rendered as a code block on the pipeline page); choosing script semantics now means a future chartbook run can execute the value as-is with no format change. If variants are ever needed (setup vs. build, per-OS commands), the natural extension is a [project.build] table with named keys — deferred until actually needed.

Custom site pages: site_dir#

Unchanged semantics, new default. site_dir is the markdown source directory for custom pages merged into the generated site (the built HTML output remains ./docs/). It now defaults to ./docs_src/site/, auto-enabled when that directory exists.

The default is deliberately not ./docs_src/ itself: that directory also holds chart_docs_path/dataframe_docs_path fragments (docs_src/charts/*.md, docs_src/dataframes/*.md), and since site_dir copies everything recursively and auto-discovers every .md, a docs_src/ default would publish each fragment twice — once embedded in its generated chart/dataframe page and again as an orphan standalone page. Convention: fragments live in docs_src/charts/ and docs_src/dataframes/; site pages live in docs_src/site/.

Removed: chartbook_format_version#

Deleted with no replacement, which is safe because “absent” is well-defined: a file without a version key is a v2 file (v1 files are detected structurally by the presence of [config] and rejected with a pointer to the migration script). If the format ever breaks again, a format_version key gets reintroduced at that point, with absence defined to mean v2 — the standard pattern for tools that add versioning late. A version field is cheap insurance for a tool with thousands of external users; for a format whose every instance can be migrated in one afternoon, it is dead weight. The v1 implementation also never actually protected anything: it compared minor versions, a no-op at 0.0.x.

Entity sections: field renames#

[charts.*], [dataframes.*], [notebooks.*], and [notes.*] keep their structure, but field names are de-prefixed — the same section-redundancy fix applied to [project]. Three conventions govern the new names:

  1. The section provides the context. Inside [charts.gdp], chart_name says “chart” twice; it is just name. Likewise description, tags, docs everywhere.

  2. Every entity’s primary artifact is path. Chart → its HTML file, dataframe → its parquet file (what data.load reads), notebook → its .ipynb, note → its markdown file. Secondary artifacts get a <kind>_path name (excel_path).

  3. The docs pair is docs / docs_path. docs holds inline markdown, docs_path references a file; exactly one is required, same rule as v1.

Renames that apply to more than one section: topic_tagstags, *_docs_strdocs, *_docs_pathdocs_path.

Charts ([charts.*]):

v1

v2

chart_name

name

short_description_chart

description

dataframe_id

dataframe

topic_tags

tags

data_frequency

frequency

lag_in_data_release

release_lag

data_release_timing (drift variant data_release_dates)

release_timing

data_series

series

data_series_start_date

start_date

past_publications

publications

path_to_html_chart

path

path_to_excel_chart

excel_path

chart_docs_path

docs_path

chart_docs_str

docs

Unchanged: units, mnemonic, observation_period, seasonal_adjustment, and the governance trio date_cleared_by_iv_and_v, last_legal_clearance_date, last_cleared_by (domain vocabulary with no redundancy to remove).

Dataframes ([dataframes.*]):

v1

v2

dataframe_name

name

short_description_df

description

topic_tags

tags

data_sources

sources

data_providers

providers

links_to_data_providers

provider_links

type_of_data_access

access_types

need_to_contact_provider

contact_required

data_on_pre_approved_list

pre_approved

data_license

license

license_expiration_date

license_expiration

provider_contact_info

provider_contact

restriction_on_use

restrictions

how_is_pulled

pull_method

path_to_parquet_data

path

path_to_excel_data

excel_path

dataframe_docs_path

docs_path

dataframe_docs_str

docs

Unchanged: date_col.

Notebooks ([notebooks.*]):

v1

v2

notebook_name

name

notebook_description

description

notebook_path

path

is_publishable

publishable

Notes ([notes.*]):

v1

v2

path_to_markdown_file

path

Semantics are untouched — these are mechanical renames only, applied by the same migration script pass as the section consolidation.

Rationale: identity and namespacing#

(Preserved deliberately — this is the reasoning behind the scope/name decision, recorded so future changes can be weighed against it.)

The long-term vision is a global pipeline catalog — PyPI/npm/JSR, but for data pipelines. That raises the question of what a pipeline’s identity is. The design space has been thoroughly explored by package ecosystems:

  • Deno ran the URL experiment. It launched with raw URLs as package identity (import from "https://deno.land/x/...") and spent years walking it back to JSR’s @scope/name, because URLs make poor identities: repos get renamed, orgs restructure, hosting moves, and every such event breaks every reference.

  • Go modules chose location-as-identity (github.com/user/repo) and pay for it with permanent coupling to the hosting path plus vanity-URL machinery to escape it.

  • Docker’s model aged best: a short name (ubuntu), an optional namespace (library/ubuntu), an optional registry prefix, and well-defined resolution from short to fully qualified. Users type the short thing; the system stores the canonical thing.

ChartBook follows the Docker/JSR shape: scoped names are canonical, URLs are accepted as input and normalized, bare names resolve against the local catalog when unambiguous. Consequences of this choice:

  • Identity survives hosting changes. ftsfr/crsp_treasury stays valid if the repo moves hosts; only the location mapping (catalog entry or future registry record) updates.

  • The host is deliberately not part of the ID. Go-style github.com/ftsfr/... would buy registry independence at the cost of welding identity to hosting — the exact trap. If host disambiguation is ever needed, an optional registry prefix can be added later, Docker-style, without breaking existing IDs.

  • The local catalog is already the right indirection layer. It maps ID → path today; a future registry maps ID → repo URL. The catalog then acts like a lockfile of installed pipelines, and chartbook catalog add ftsfr/crsp_treasury (no path) can someday fetch. Scope ownership/squatting is a registry problem to solve when a registry exists; locally, the catalog is the authority, so conflicts are impossible until then.

  • Ambiguity is an error, not a guess. Bare-name resolution failing loudly when two scopes share a name matches the format’s general infer-or-error principle.

The cost of adopting this now is near zero — a string grammar and a three-step lookup — which is what makes it the future-proof choice: nothing about the format changes when the registry, version pinning, or cross-pipeline dependencies arrive.

Future work#

Kept on the table, explicitly out of scope for v2:

  • Cross-pipeline dependencies — a requirements.txt for pipelines, pinned by commit:

    [dependencies]
    "ftsfr/fed_yield_curve" = { rev = "a1b2c3d" }
    "ftsfr/crsp_treasury"   = { path = "../crsp_treasury" }   # local, unpinned
    

    The v2 identity work exists partly to serve this: dependency declarations, data.load, and catalog keys all share one naming scheme, and the catalog’s path entries are structurally path-dependencies already, so registration and dependency resolution can converge on one mechanism.

  • chartbook run — executing the build field (needed the moment dependencies exist, so a dependency pipeline can be built automatically). The script semantics of build were chosen so this needs no format change.

  • A global registry mapping scope/name → repo URL + metadata.

Open questions#

(none — the entity field de-prefixing question was resolved in favor of batching the renames into this break; the approved list is in Entity sections.)

Migration#

No backward compatibility. The v2 loader detects v1 files (presence of [config]) and fails with a message pointing at the migration script.

A one-shot script (scripts/migrate_toml_v2.py, deleted after use) rewrites v1 → v2 mechanically:

v1

v2

[config] type

[project] type (then usually dropped as inferable)

[config] chartbook_format_version

removed

[site] title + [pipeline] pipeline_name

[project] name (nearly always identical; on mismatch the script warns and prefers pipeline_name)

[site] author, [pipeline] lead_pipeline_developer

[project] maintainer

[site] copyright

[project] copyright (dropped if == current year)

[site] logo_path / favicon_path

[project] logo / favicon (dropped if empty)

[site] enable_data_download

[project] enable_data_download (dropped if false)

[pipeline] id

[project] id, scoped via git remote (dropped if it equals the derived value)

[pipeline] pipeline_description

[project] description

[pipeline] contributors, git_repo_URL, README_file_path, site_dir

[project] contributors, repo_url, readme, site_dir (defaults dropped)

[pipeline] os_compatibility, runs_on, runs_on_grid_or_windows_or_other

[project] os_compatibility — slash-separated OS strings split into arrays; unrecognized free text kept as a single-element array

[pipeline] build_commands, software_modules_command

[project] build (concatenated in module-command-first order)

top-level webpage_URL

[project] site_url

[pipelines.X] path_to_pipeline

[pipelines."scope/X"] path (scope from the target’s git remote); {Unix=…, Windows=…}{unix=…, windows=…}

all [charts.*] / [dataframes.*] / [notebooks.*] / [notes.*] fields

renamed per the entity rename tables, including drift variants (data_release_datesrelease_timing)

Files to migrate: the 19 ftsfr pipeline repos + their catalog, the cookiecutter template (Jinja preserved), examples/{fred_charts,yield_curve,catalog}, test fixtures, create_global_catalog() output, and any ~/.chartbook/chartbook.toml. The legacy src/chartbook/templates/chartbook.toml (an orphaned [theme]/[site] stub) is deleted.

Implementation plan#

Ordered so each phase leaves the repo green. Internal code adopts the v2 names throughout — no compatibility shims — since every consumer lives in this repo.

Phase 1 — Loader core (manifest.py, conf_validation.py, errors.py)

  • Parse [project]; replace DEFAULT_CONFIG; type-inference table with the three error cases; v1 detection ([config] present) → friendly error.

  • Identity derivation (explicit id → git remote + dirname → bare dirname); reserve and reject @rev.

  • Unknown-key warning for [project].

  • Internal manifest keyed by v2 names (manifest["project"]["name"], resolved type, resolved id); SiteConfig.from_manifest reads name/maintainer/copyright; get_logo_path/get_favicon_path read project.logo/favicon; site_dir default-if-exists logic.

Phase 2 — Consumers (markdown_generator.py, build_docs.py, publish.py, diagnostics.py, Jinja templates in docs_src_pipeline/ and docs_src_catalog/)

  • Templates: pipeline_manifest.pipeline.pipeline_namepipeline_manifest.project.name, etc.; render build as a code block; drop the duplicated-URL “Pipeline Web Page” row in favor of site_url.

  • publish.py reads project.readme; diagnostics field list switches to v2 names.

Phase 3 — Identity resolution & CLI (data.py, cli.py, config.py)

  • Three-step resolution (scoped exact → bare unique-or-error → URL normalize) behind one helper used by data.load, data.get_path, chartbook ls.

  • catalog add: scoped key from target’s git remote; quoted-key TOML emission; path entry shape.

  • catalog init / create_global_catalog() emit v2; chartbook config catalog validation uses resolved type.

Phase 4 — Policy (diagnostics.py, build_docs.py)

  • Parse [policy]; current hardcoded lists become the default warn-only policy; mode = "strict" fails the catalog build; diagnostics page reports against the active policy.

Phase 5 — Migration & fleet update

  • scripts/migrate_toml_v2.py implementing the mapping table above (drift-variant normalization included, --check dry-run mode).

  • Run over: test fixtures, examples/, cookiecutter template + example/, all 19 ftsfr repos + catalog, ~/.chartbook/. Delete the orphaned templates/chartbook.toml.

Phase 6 — Tests

  • Update fixtures and assertions across test_manifest, test_config, test_conf_validation, test_catalog_add, test_build_docs, test_build_command, test_integration, test_data, test_config_cli, test_errors.

  • New coverage: type-inference truth table (incl. the three error rows), identity derivation precedence, bare/scoped/URL resolution and the ambiguity error, @rev rejection, unknown-key warning, [policy] warn vs. strict, v1-file rejection message, site_dir default gating, migration script golden files.

Phase 7 — Docs & release (see next section), version bump, changelog entry describing the break.

Documentation plan#

Reference material to rewrite, and where the rationale is preserved:

  • docs_src/configuration.md — full rewrite as the v2 reference: the [project] key table, type-inference rules, minimal-file example, catalog registry and [policy], site_dir conventions. Cross-links to this design doc for the “why”.

  • This document (docs_src/design/toml-format-v2.md) — added to the site toctree so the reasoning (especially the namespacing rationale) is published, not buried in git history.

  • docs_src/user-guide/concepts.md — new “Pipeline identity” section: scoped names, the three lookup forms, ambiguity behavior.

  • docs_src/user-guide/catalog-projects.md — scoped keys, catalog add behavior, [policy] with the permissive-format/catalog-owns-policy framing.

  • docs_src/user-guide/pipelines.md, build-pipeline.md[project] examples, the build field and its script semantics.

  • docs_src/getting-started.md, docs_src/index.md, README.md — update data.load(...) examples to scoped names.

  • docs_src/cli-reference.mdcatalog add/ls output changes.

  • Bundled skills (src/chartbook/skills/manifest_files.md, SKILL.md, catalog_system.md) — rewritten for v2; these are what AI assistants read, so they must not teach the dead format.

  • llm/llms.txt, llm/llms-full.txt, docs_src/llms-txt.md — regenerated with v2 examples.

  • docs_src/changelog.md — breaking-change entry summarizing v1 → v2 and linking here.

  • Cookiecutter repo (cookiecutter_chartbook) — template chartbook.toml (Jinja preserved), rendered example/, and its README.