Imported from owid/etl (
.claude/skills/edit-faust-metadata/SKILL.md). Install upstream withnpx skills add owid/etl --skill edit-faust-metadata. Copyright stays with the author.
Edit FAUST & metadata
Edit the user-facing text of charts, MDims, and indicators from a plain request in the terminal, iterating always on a staging server — production is never written to directly. The skill is designed for both data scientists and non-coders: the target can be referenced by any link the user has at hand.
Two modes:
- Edit mode (default) — conversational edits: resolve the reference, route the edit to the right layer, check the blast radius, apply, verify on staging, and ship through a PR with an automated Codex review loop.
- Dump + compare mode (only on explicit request — never offer it) — generate a Markdown FAUST report for one or many charts/indicators, let the user edit it as the desired target state, then reconcile the live config against it. See Dump + compare mode.
Field scope and critical inheritance rules
Chart title / subtitle / footnote resolve ONLY from presentation.grapher_config.{title, subtitle, note}. Do NOT fall back to variable.title, presentation.title_public, display.name, or description_short — those are data-page fields and produce text that does not match what Grapher actually renders.
description_short / description_key resolve from the namesake top-level fields on VariableMeta — not from grapher_config.
Not every chart has presentation.grapher_config populated: some charts are edited only in the admin DB, so the ETL metadata looks empty. Treat those fields as chart-level (see routing below) rather than inventing a fallback. See .claude/projects/-Users-parriagadap-etl/memory/feedback_chart_faust_inheritance.md for the full rule.
Field mapping — user vocabulary → where the field can live:
| User says | Config key(s) | Chart-config-expressible? | Indicator-level (ETL meta.yml)? |
|---|---|---|---|
| title | title |
yes (patch) | presentation.grapher_config.title |
| subtitle | subtitle |
yes (patch) | presentation.grapher_config.subtitle |
| footnote / note | note |
yes (patch) | presentation.grapher_config.note |
| description / "About this data" | description_short, description_key |
no | yes (top-level VariableMeta) |
| WYSK / "What you should know about this indicator" | description_key |
no | yes (top-level VariableMeta) |
| unit / short unit | unit, short_unit, display.unit |
display-only via dimensions | yes |
| legend / series label | dimensions[i].display.name |
yes (patch) | display.name |
| public title (data page) | presentation.title_public |
no | yes |
| source shorthand | presentation.attribution_short |
no | yes (does NOT inherit from origin — set explicitly) |
| selected countries / default view | selectedEntityNames, selectedEntityColors |
yes (patch) | presentation.grapher_config.selectedEntityNames |
| axis labels, map settings, colors | xAxis/yAxis, map.*, baseColorScheme |
yes (patch) | presentation.grapher_config.* |
"Chart-config-expressible" = the field can be set on an individual chart's config (its patch). Indicator-only fields (description_short/key, units, title_public, attribution_short) are read by every surface that uses the variable — editing them always has a potential blast radius.
Step 0 — resolve the reference
Run the resolver on whatever the user pasted:
.venv/bin/python .claude/skills/edit-faust-metadata/scripts/resolve_target.py <reference> [--branch <b>] [--json] [--no-db]
Accepted references:
| Input | Example | Resolves to |
|---|---|---|
| Live chart URL | https://ourworldindata.org/grapher/life-expectancy?country=FRA |
chart (slug) |
| Staging chart URL | http://staging-site-my-branch/grapher/life-expectancy |
chart (slug) |
| Admin chart edit URL | https://admin.owid.io/admin/charts/104/edit |
chart (id) |
| Admin collection/MDim preview URL | .../admin/grapher/wb%2Flatest%2Fincomes_pip%23incomes_pip?indicator=mean |
mdim / mdim-view |
| Bare slug | life-expectancy |
chart or mdim |
| Chart id | 104 |
chart |
| Indicator catalogPath | grapher/wb/2026-03-24/world_bank_pip/incomes#mean__... |
indicator |
| Explorer URL | .../explorers/poverty-explorer |
out of scope → point to /create-explorer |
The resolver needs the branch's staging DB for slug/id lookups (--no-db does parse-only identification, useful before the staging server exists). It reports: kind, chart id/slug/published state, isInheritanceEnabled, which top-level keys are in the chart's patch, the variables on the chart (with whether each has an ETL grapher config), matched MDim view + its overrides, the candidate ETL files to edit, and ready-made staging/admin URLs.
Notes baked into the resolver — don't re-derive them by hand:
- MDims are served at
/grapher/<slug>too — slug lookups checkmulti_dim_data_pagesaftercharts. - Old slugs resolve through
chart_slug_redirects; the canonical slug is reported. - Duplicate slugs prefer the published chart; editing an unpublished chart gets a warning.
- MDim choice values can carry deliberate trailing spaces — dims are matched stripped but written back raw (memory:
reference_mdim_choice_name_trailing_space). - Never hand-build
staging-site-<branch>hostnames — branch names get normalized and truncated to 28 chars (etl.config.get_container_name); a wrong name silently serves a different environment. - When a
<short_name>.meta.override.ymlexists next to the meta.yml, the resolver lists it first — that's the manual-curation surface (the main meta.yml is likely auto-generated; see the route (a) note below).
Edit routing — which layer gets the edit
Primitives:
- Explicitly set at chart level ⇔ the key exists in the authored layer (the
chart_configsrow named bypatchConfigId). Never judge this from the rendered config — that has every inherited value merged in, so an inherited title looks identical to an override there (etl/indicator_upgrade/indicator_update.py:207). - Inheritable ⇔ the chart's primary y variable has an ETL grapher config (
variables.patchConfigIdETL IS NOT NULL) and that config carries the field.
Three routes:
- (a) Indicator ETL metadata — edit the garden
.meta.yml→ rebuild garden+grapher →STAGING=1 etlr grapher://grapher/<ns>/<ver>/<ds> --grapherto upsert to staging. Check for a<short_name>.meta.override.ymlnext to the meta.yml first — the ETL merges it on top of the built metadata automatically (etl/steps/__init__.py), and datasets that carry one (WDI is the flagship:wdi.meta.override.yml) auto-generate their main.meta.yml, so manual curation MUST go into the override file — an edit to the auto-generated file builds fine but is silently lost on the next regeneration. The resolver lists the override file first when it exists. - (b) MDim step files — edit the MDim
.config.yml/.py→STAGING=1 .venv/bin/etlr export://multidim/<ns>/<ver>/<name> --export --private. - (c) Chart config on staging —
scripts/update_chart_config.py(guarded, staging-only; see below). Reaches production only via chart-diff approval + chart-sync after merge.
Default rule: inherited fields get fixed in the ETL files, never patched via the admin API. If the rendered text comes from the indicator's metadata or an MDim's step files, the edit belongs in those files — routes (a)/(b). File edits are the durable source of truth: they survive rebuilds and dataset updates, reach every surface, and go through code review. A route-(c) patch on an inherited field creates a chart-level override that shadows the source from then on — the chart silently stops tracking future metadata improvements. Reserve route (c) for fields that are genuinely chart-level (already in the patch, or with no inheritance path) or for a deliberate, user-confirmed decision to scope a change to one chart.
Target = chart, field F:
- F is indicator-only (description_short/key, unit/short_unit, title_public, attribution_short, indicator-level display.name) → route (a). Blast radius is mandatory first. Exception: the user wants a legend/series name changed on this chart only →
dimensions[i].display.namevia route (c) — offer both, default to fixing the source. - F ∈ {title, subtitle, note}:
- Key present in the chart's
patch→ route (c) (the patch wins regardless of inheritance). - Key absent + inheritance enabled + single y indicator + inheritable → the rendered text IS the indicator's → route (a) by default (fix at the source, per the default rule above). Route (c) is only the scope-down option in the blast-radius ask, when the user confirms the change should apply to this one chart and not the other surfaces — and make the trade-off explicit: the patch permanently detaches the field from the indicator's metadata.
- Key absent + inheritance disabled, or multi-y-indicator chart (inheritance baseline ambiguous — same conservatism as
indicator_update.py), or no ETL grapher config → route (c).
- Key present in the chart's
- Entity selection / colors / axis / map settings → chart-config-only → route (c). For selection edits, check the entities actually have data in the indicator (see the
check-empty-entitiesskill's availability lookup), and keepselectedEntityColorsin step with the selection: on a rename, move the color entry from the old name to the new one (deleting it discards a deliberately assigned color — a visual regression); on a drop, delete it. Same rule ascheck-empty-entities' fix guidance — keep the two in sync.
Target = MDim view, field F:
- Overridden at view level (
config.*for chart fields,metadata.*for indicator fields in the yaml, or programmatic writes in the.py— grep forview.metadata[...],_assert_and_replace,_replace_*) → route (b). Mind mirror constants: MDim.pyfiles hard-copy garden bullet texts underOLD_*/NEW_*assertions — every garden text edit needs the matching constant edit; grep the repo for fragments of any text you change. - Not overridden → inherited from the view's primary y indicator → route (a) (the grapher upsert refreshes the view; nothing extra needed on the MDim), or scope down to a new view-level override (route b) if the blast radius shows the indicator is shared.
- Never write
multi_dim_x_chart_configsor PUT MDim configs directly — they're rebuilt from the step files on every export.
Target = MDim (whole collection): top-level title, default_selection, common_view_config, config-level definitions → route (b).
Target = indicator: → route (a); blast radius on its variable ids.
Narrative charts (rare): their config is a patch over the parent chart. Edit via AdminAPI(OWIDEnv.from_staging(branch)).get_narrative_chart(id) / update_narrative_chart(id, cfg) — and audit configFull, never the bare patch (it lacks every inherited field). Note configFull is the stored rendered config (the materialized parent+patch merge, not a live one): it goes stale when the parent is edited without the child being re-saved — see the narrative-children section below for the re-save that fixes that. When a narrative chart is affected indirectly — because you edited its parent's FAUST — follow Narrative-chart children of an edited FAUST field.
Writing new text into a garden .meta.yml (route a)
Match the file's own authoring pattern before writing a single sentence — your diff should look like the rest of the file.
-
A file that keeps its text in
definitions:gets the new text there too, never inline under the variable. When the.meta.ymldeclares its sentences asdefinitions:entries (anchors and/or Jinja<% if dim == … %>branches) and the variables reference them as{definitions.<key>}, add new text as new definitions at the top of the file, next to the related definitions, and reference them from the variable. Inline prose parses and renders fine, so nothing fails — it just leaves the file with two authoring styles and the text unreusable and un-Jinja-able. The bigger the dataset, the more this matters: in a.meta.ymlwith hundreds of variables, definitions-at-top is what keeps the file readable — all the prose lives in one place a reviewer can read end to end, and the variable blocks stay skimmable as short lists of references instead of walls of text. Default to it even for text used by a single variable. Slot each key where the definitions order already puts its neighbors (these files usually track table order), name it in the file's convention (description_key_<topic>), and keep the reference list's order so bullet order doesn't move. In a|-block scalar keep each bullet on one long line — a wrapped line inserts a real newline into the rendered text. -
Before adding a definition, grep the existing ones for text that already says the same thing. New text often duplicates a bullet the file already carries under a different name (a source/comparability caveat, a classification note) and that other indicators already reference. Reuse beats near-duplication, and there are two ways to get it — put both to the user, don't pick silently:
- reference the existing key from the new variable (the new wording is dropped); or
- keep the new, better wording but place it under the existing key's name, replacing that key's text.
The second reaches every indicator already referencing that key, so blast-radius the shared key first —
blast_radius.py --anchor <key> --meta-file <path>expands a definitions key to its variables — and report which surfaces the reworded text lands on. Also check the new wording still fits the key's name and the distinction it encodes: a key called…_national_estimatesshould not end up asserting the data is harmonized. -
Read the whole rendered list before adding to it — new text must not read as redundant. This applies to any field but bites hardest on
description_key, where bullets are read as a set underdescription_shortand the chart's title/subtitle. Render the existing bullets for the view being edited (not the raw YAML — a Jinja branch may already say your sentence for that dimension value) and ask what the new one adds. If it only says an existing bullet more fully, edit that bullet instead of adding a second; if it repeats another bullet at the same level of detail, drop it. Expandingdescription_shortis fine and often expected — that one-sentence summary is meant to be unpacked here; the thing to avoid is a bullet that restates it without going further. Field-by-field style rules, including this one, are inowid-metadata-generation. -
Then widen the search past the file, to the other datasets carrying the same text. Metadata boilerplate travels: the same source caveat, classification note, or methodology sentence is often pasted into several datasets'
.meta.yml(and mirrored in MDim.pyconstants). Search a few distinctive 5–8 word fragments of the text acrossetl/steps/— near-duplicates differ by a word or two, so one long exact-match search finds nothing while three short ones find everything:rg -l -i "harmonizes labor statistics from national sources" etl/steps/ rg -n -i "may not be directly comparable across countries" etl/steps/ | head -30Report every hit with a recommendation, and use it in both directions: when your new wording supersedes theirs, propose the same fix there; when a sibling dataset already words the point better, adopt its wording instead of minting a third variant. Do not fold other datasets into the current PR — they have their own owners, their own charts, and their own review; the current PR stays scoped to the indicator at hand and the sibling fixes ship as a separate PR (offer to open it, and list the affected datasets as a proposed follow-up in the current body so the item can't get lost).
-
Prove a pure-refactor edit is text-neutral without building the dataset. Moving text into definitions must not change one rendered character. Resolve both versions of the file with the catalog's own loader and compare the resolved
tables:section —definitions:never reaches the output, so identicaltables:means identical metadata for every variable at every dimension value. That's both stronger and far cheaper than a garden+grapher rebuild:from owid.catalog.core.utils import dynamic_yaml_load, dynamic_yaml_to_dict from owid.catalog.core.yaml_metadata import merge_with_shared_meta resolved = dynamic_yaml_to_dict(dynamic_yaml_load(merge_with_shared_meta(Path(p)), {}))Diff per variable and per field, so an intended change shows up as exactly one entry and collateral ones can't hide. Then render the Jinja for every dimension value the edited variable takes (
owid.catalog.core.jinja._expand_jinja_text(text, {"<dim>": value})) and show the user the bullets as a reader sees them — that also confirms which branches render empty and drop out of the list.
Blast radius — notify and ask first
Before applying an edit, report every other surface it would change:
.venv/bin/python .claude/skills/edit-faust-metadata/scripts/blast_radius.py --branch <b> \
(--variable-id N ... | --catalog-path 'grapher/...#col' ... | --anchor NAME --meta-file PATH | --chart-id N) \
[--field subtitle] [--json]
Run it whenever:
- the route is (a) — always (indicator fields feed every surface using the variable);
- the route is (b) and the edit touches a shared block (
common_view_config, config-leveldefinitions, a garden definition consumed by several views); - the route is (c) and the chart has narrative-chart children or gdoc embeds (the reporter checks).
It sweeps: charts (with --field, charts shielded by their own patch override of that field are listed separately — they will NOT change; for the chart-text fields title/subtitle/note, charts with no inheritance path — variable not a y series, several y series, or inheritance disabled — are also listed separately and excluded from the beyond-target count, since grapher only inherits chart config from a single-y, inheritance-enabled parent), MDim views, explorer views (legacy CSV explorers are invisible to these tables — caveat is printed), narrative charts, and article references (informational: embeds don't break, but the displayed text changes).
Report it specifically, never as counts alone. "13 charts, 3 MDim views" tells the user nothing they can check: they can't see whether the affected surfaces are the ones where the new wording actually fits. Pass on what the script prints, for every surface — not just charts:
- the indicators carrying the edit, with how many charts each feeds, so a lopsided distribution is visible;
- charts as links, each annotated with the indicator it comes through, published state included;
- MDim views as links — the reader URL with the view's dimension query string, plus the admin collection preview;
- explorers as links, with the number of affected views in each;
- narrative charts as admin links, marking the ones shielded by their own override;
- article references, which change what readers see even though the embeds keep working.
Then read the slugs before asking, and say what you notice: a slug can reveal that the edit lands somewhere the wording contradicts (a …-modeled-vs-national chart receiving a sentence about harmonized data), which is the finding the user needs and a count can never carry. Keep the list in the chat message, not only in a file — and if it's long, lead with the surfaces that matter and say how many more there are.
blast_radius.py stays the tool for this skill: its value is the per-field inheritance analysis (which surfaces are shielded by their own patch, which have no inheritance path), which decides whether an edit actually reaches a surface — a question no generic sweep answers. For the plainer question "what references this object at all", including surfaces this script doesn't cover (data insights, static viz, key-chart slots, WordPress), use find-chart-references.
Decision rule: if surfaces beyond the one the user pointed at are affected (count > 0), STOP and ask the user before applying:
- Proceed broadly — the text is wrong everywhere; fix at the source.
- Scope down — name the concrete alternative: a view-level override in the MDim (route b) or an explicit chart-level value (route c), leaving other surfaces untouched.
- Abort.
If the beyond-target count is zero, skip the ask and proceed. When the report lists narrative-chart children (affected or shielded), also run the section below before the checkpoint.
Narrative-chart children of an edited FAUST field
Changing a chart's title/subtitle/note — whether via the indicator's ETL metadata (route a) or the chart's patch (route c) — also reaches its narrative-chart children. Check them with the same logic as /update-dataset step 7's stale-FAUST pass. For every child the blast radius lists (narrative_charts.parentChartId, plus parentMultiDimXChartConfigId when the parent is an MDim view):
- Child inherits the field (key absent from its patch — blast radius lists it as affected): the child will pick up the parent's new text, but not immediately — its rendered config (which is also what
get_narrative_chart(id)["configFull"]returns) is rematerialized only when the child itself is saved, so the stored config and the render keep serving the old text until then. Force re-derivation by re-saving the child's stored patch unchanged —AdminAPI(OWIDEnv.from_staging(branch)).update_narrative_chart(id, patch); never re-PUT the fetchedconfigFull, which submits the stale full and pins the old text into the child's patch as an override (mechanics in/update-datasetstep 7's narrative-chart notes). Then verify with a freshget_narrative_chart(id)["configFull"]read and list the child at the checkpoint so the user can eyeball the new text in the narrative framing. - Child overrides the field (blast radius marks it shielded): compare its override against the parent's pre-edit text using
_find_stale_faust_overrides(child_patch, pre_edit_parent_config)fromapps/indicator_upgrade/upgrade.py(near-identical after markdown-link stripping = stale; substantially different = intentional). The pre-edit parent config MUST be the snapshot taken in workflow step 6, before the edit was applied — comparing against the already-updated parent makes a child that froze the old wording look "substantially different" and misclassifies the stale copy as an intentional rewrite:- Stale copy — the child froze the parent's old text at creation time and no longer tracks it. Propose setting the child's field to the parent's new exact text, which drops the key out of the patch and restores inheritance. Always ask the user before changing it — narrative-chart text is reader-facing editorial content; never fold the child fix silently into the parent edit. Apply via
AdminAPI.update_narrative_charton staging. - Intentional rewrite — leave it, but flag it at the checkpoint if the parent's new text now contradicts the child's framing (e.g. the parent's subtitle changed a definition the child's rewrite still states the old way).
- Leave numeric display overrides (
tolerance,numDecimalPlaces, …) alone unless asked — they may be intentional.
- Stale copy — the child froze the parent's old text at creation time and no longer tracks it. Propose setting the child's field to the parent's new exact text, which drops the key out of the patch and restores inheritance. Always ask the user before changing it — narrative-chart text is reader-facing editorial content; never fold the child fix silently into the parent edit. Apply via
Like the parent edit itself, child fixes land on staging only and ride chart-diff to production after approval + merge.
Workflow (edit mode)
The single checkpoint rule: nothing is committed or pushed before the user's explicit go-ahead. Everything up to the checkpoint happens on the branch + staging server only.
- Parse the request; run
resolve_target.py --no-dbfor instant identification feedback to the user. - Create the branch + draft PR:
.venv/bin/etl pr "<title, no emoji>" data(never manual branching). This spins up the staging server the whole workflow depends on. Name the title after the field that actually changes, using the team's own shorthand where one exists — when the edit is only aboutdescription_key, write WYSK, not the full "What you should know about this indicator" (e.g.Add WYSK to the ILO gender wage gap indicator). The shorthand is in CLAUDE.md's glossary, so every colleague reads it, and it leaves room in the title for the indicator being edited. - Wait for staging readiness: retry
OWIDEnv.from_staging(branch).read_sql("SELECT 1")(builds take a few minutes). - Run
resolve_target.pywith the DB; pick the route via the decision tree. - Run
blast_radius.pyper the rules above; ask the user if other surfaces are affected. - Apply the edit. If the blast radius listed narrative-chart children, snapshot each parent's pre-edit rendered config FIRST — the narrative-children pass compares against this, and after the edit the pre-edit text is gone. Save the rendered config from the staging DB to a scratch file, using the query that matches the parent type: for a chart parent (child listed via
parentChartId),SELECT cc.config FROM charts c JOIN chart_configs cc ON cc.id = c.configId WHERE c.id = <id>; for an MDim-view parent (child listed viaparentMultiDimXChartConfigId),SELECT cc.config FROM multi_dim_x_chart_configs mx JOIN chart_configs cc ON cc.id = mx.chartConfigId WHERE mx.id = <mx_id>(the child'sparent_view/ the view'smx_idin the blast-radius--jsonoutput). MDim view configs can be thin — if the edited field is absent from the view's rendered config because it inherits from the indicator, also record the field's pre-edit rendered text from the staging indicator metadata API before editing. Then:- route (a): edit the garden
.meta.yml, following the style rules below; - route (b): edit the MDim yaml/py (mind mirror constants);
- route (c):
update_chart_config.py --branch <b> --chart-id <id> --set ... [--dry-run first].
- route (a): edit the garden
- Reflect on staging without committing:
- route (a):
.venv/bin/etlr garden/<ns>/<ver>/<ds> grapher/<ns>/<ver>/<ds> --privatethenSTAGING=1 .venv/bin/etlr grapher://grapher/<ns>/<ver>/<ds> --grapher(the MySQL upsert takes ~50 s+/dataset — warn the user; the automatic rebuild after the eventual push re-does it harmlessly). Needed because the staging auto-rebuild only sees pushed code. - route (b):
STAGING=1 .venv/bin/etlr export://multidim/<ns>/<ver>/<name> --export --private. - route (c): already live on staging.
- route (a):
- Run the metadata quality checks scoped to the edit (next section); fix findings and re-run the affected steps.
- Verify on staging (section after); show the user the preview links.
- CHECKPOINT — show: the
git diff(routes a/b) and/or the chart-patch JSON diff (route c), staging preview links (strip the.tail6e23.ts.netsuffix from admin links), the blast-radius summary, and any unresolved check findings. Wait for the user's explicit go-ahead. - After the go-ahead, hands-off:
make check;- commit
🔨🤖 <description>withCo-Authored-By: Claude <model name> <noreply@anthropic.com>; - first push needs the upstream:
git push -u origin <branch>, then verifygh pr view --json filesis non-empty; - PR description via
gh pr edit— first line is the attribution blockquote (> _Written by Claude <model name> — @<handle> at the wheel._), then: what changed and why (public facts only), the blast-radius summary, any route-(c) DB-only edits (they have no file diff — describe them explicitly and note they ride to production via chart-diff approval), and any#dod:follow-ups ("create in admin"); - if the PR has committed files: post a bare
@codex reviewcomment, record its exact timestamp, and spawn thepr-babysitterskill's background agent to watch CI, judge/fix findings, reply + resolve threads; - if the PR is DB-only (zero committed files): skip Codex entirely and tell the user the path to production is chart-diff approval in the Wizard + merge;
- suggest a human reviewer from the dataset's owners. Read
dataset.ownersin the garden.meta.ymlof every dataset the edit touches (first entry = accountable owner). More than one candidate → show the options and let the user choose, never pick for them; exactly one → name them and ask to confirm; the only owner being the user directing the work → say so instead of proposing a self-review. Add withgh pr edit <n> --add-reviewer <handle>, resolving handles from CLAUDE.md's team table (never guess a handle — a wrong one pings a real person). Carry the ask forward as an open item until it's requested or declined.
Metadata quality checks (before the checkpoint)
Style rules for writing text live in .claude/skills/owid-metadata-generation/SKILL.md — follow its field-by-field guidelines whenever composing new text (description_short must not repeat the title; plain language, expand acronyms; description_key ordered data-specific → methodology → caveats; curly apostrophes; American English; per-field guidance in schemas/definitions.json).
The check suite is also defined there (see "Metadata quality checks" in that SKILL — the canonical list, mirroring /update-dataset §6b/§6c): typos (/check-metadata-typos), Jinja spacing (/check-metadata-spacing), style guide (/check-metadata-style), the manual clarity checklist, link + #dod: verification, the dimension sweep, and adversarial claims verification (/adversarial-data-review).
Scoping rules specific to this skill:
-
Adversarial claims verification is MANDATORY here, but only on the metadata being added or edited — never on the data. Run
/adversarial-data-reviewscoped to the new/changed text: treat every added or edited sentence as a claim and verify it against the producer's documentation (fetch what's behind the links in the edited text and the dataset's snapshot.dvc— the link check only proves URLs resolve; this reads what they say). Skip the skill's data-value cross-checks, anomaly scans, and indicator prioritization entirely — no data changed. Unedited metadata is out of scope too. This keeps the pass cheap (a handful of web calls) while catching the failure mode nothing else covers: text that is well-formed, well-styled, and factually wrong (stale methodology attributions, scope overclaims, misread units in prose). -
Dimension sweep — every sentence must hold at every dimension value it renders on. Text written for the view the user pointed at then renders on all the sibling views of a dimensional indicator (Jinja over
<dim>, or adefinitions:key several variants reference). Render it for every value the indicator takes (recipe in the route-(a) section above) and read each output as a reader of that chart, asking what the view already restricts: a caveat that the data doesn't control for X is wrong on the variant grouped by X; a scope word like "all employees" overclaims on a variant filtered to a subgroup; a sentence about a toggle is wrong on views that exist for only one choice of that dimension (see also item 4 of Target-driven description_key restructuring). Prefer fixing it by qualifying the wording so it's true everywhere — often one word, and nothing extra to maintain — and add a Jinja branch or a view-level override only when the qualified version loses something the reader needs. Run it before the checkpoint: automated reviewers catch this class reliably, so a sweep of your own saves a review round. -
Pin-coupling check — the mirror of
check-hardcoded-years' deliberate-pin signal. That audit refuses to bump a time pin when the pin's value lives in the FAUST text; this skill edits the text side of the same coupling, so check it from here too, in both directions. (i) When the edited text names a year or a figure the chart's current window produces ("increased 12-fold", "more than 95%", a ratio in the title, "the past three decades"), read the config'sminTime/maxTime/map.timebefore shipping: changing the words without the pin — or leaving words that a pin bump has already invalidated — breaks the pairing that audit deliberately preserves. Scan for numbers, not just years: "grown 300%" names no year but is entirely determined by the pinned endpoint. (ii) When adding text, an endpoint-dependent figure creates a new coupling that silently goes stale at the next data update — prefer phrasing that survives updates ("has increased more than tenfold" only if it stays true with more years), and where the figure is the point, say so in the PR body so the next update cycle's audit knows the pin↔text pair is deliberate. -
Route (c) chart-config text has no
.meta.yml— apply the style guide, the clarity checklist, and a typo pass directly to the new text. -
If a check rewrites a
.meta.yml, re-run the affected step (grapher steps with--grapher) and re-run the check to confirm zero remaining violations. -
New
[term](#dod:term)links: check thedodstable via public Datasette (SELECT name FROM dods WHERE name LIKE ...) before shipping; if missing, keep the link and list it in the PR body as a "create in admin" follow-up.
Verifying on staging
- Chart text without a browser:
curl -s http://staging-site-<branch>/grapher/<slug>.svg | grep -o '<new text fragment>'— the server-side render carries title/subtitle/note. - Indicator fields:
https://api-staging.owid.io/staging-site-<branch>/v1/indicators/<id>.metadata.json(path prefix is the full container name, not the bare branch — a wrong prefix silently serves another environment). - MDim views: the resolver's per-view collection-preview URL (
/admin/grapher/<urlquoted catalogPath>?dim=choice...). - Visual QA: hand off to the
check-chart-previewskill for a screenshot. - Big text changes: re-run the report scripts in indicator-list mode and diff against the previous output (see dump mode below).
- Jinja-templated definitions: after editing shared
definitions, rebuild garden AND grapher before reading anything — the report scripts and ad-hoc reads use the grapher channel, and a stale channel shows pre-edit metadata. Spot-check several rendered variants; dimension comparisons are type-sensitive (decile == 5vsdecile == "5"— copy the comparison form from a working definition in the same file).
Path to production
- Route (a)/(b) file edits deploy when the PR merges (normal ETL deploy).
- Route (c) staging chart edits appear in chart-diff; they are synced to production by chart-sync only after approval in the Wizard + merge. Remind the user of the pending approval.
- Never point a write at
admin.owid.ioor the production DB. The guard inupdate_chart_config.pyenforces this; don't work around it.
Close with what's still open
End the checkpoint and the final hand-off by saying what's still open — a line or two in chat, written out in the PR body when there is one. .claude/docs/open-items.md lists what tends to get dropped. This skill's usual danglers: #dod: terms to create in admin and editorial calls on pin-coupled text (waiting on someone else), the checkpoint diff itself and route-(c) chart-diff approval in the Wizard (waiting on a decision — easy to leave dangling because the merge doesn't force it), and checks scoped out or staging surfaces not previewed (nobody checked it).
The guarded chart editor (route c)
.venv/bin/python .claude/skills/edit-faust-metadata/scripts/update_chart_config.py \
--branch <b> --chart-id <id> \
[--set subtitle='New subtitle'] [--unset note] \
[--set-json selectedEntityNames='["France","Japan"]'] \
[--dry-run]
- Hard-coded staging guard: refuses master/main, asserts the resolved env is staging, prints the target host before writing. There is no production escape hatch — by design.
- It GETs the chart's patch config, applies
--set(dot-paths, string values),--set-json(typed values/arrays),--unset(deletes the key — the server re-derives inheritance on PUT, so unsetting restores the inherited value), prints the JSON diff, and PUTs back.--dry-runstops after the diff. - Run
--dry-runfirst, show the user the diff, then apply.
Dump + compare mode (explicit request only)
Produce a Markdown audit of the user-facing chart text for a set of indicators or MDim views, for editorial review or as the target file of a mass edit. Never suggest this mode proactively — enter it only when the user asks for a dump/report/audit.
Scripts (shared helpers in scripts/_common.py: grapher-channel metadata loader, inheritance resolvers, BulletLibrary, auto-slugs, preview URL):
scripts/generate_mdim_text_report.py— MDim view mode (supportscollapse_dimsand placeholder parametrization).scripts/grapher_dataset_mode.py— grapher-dataset mode (iterates every indicator column) and indicator-list mode (--indicators <cp> <cp> ...or--indicators-file <path>).
Rebuilding the MDim .config.json is done via etlr <mdim> --export --private — there is no DB-bypass helper. Change detection handles the common case: nothing changed → ~2 s; garden .meta.yml, garden data, or MDim yaml/py changed → etlr rebuilds only the affected steps.
Do not add --grapher unless you specifically need to re-upload indicator data/metadata to MySQL — it triggers a grapher://grapher/<dataset> upload step that can take ~50 s per dataset and isn't needed for the report (the script reads metadata directly from the local grapher-channel feather files). Do not add --only when you want garden/MDim edits to take effect — it skips upstream rebuilds by design; use --only --force only to re-run just the MDim step without touching anything upstream.
Fields reported
Only user-facing text. Six fields, two groups:
| Group | Fields | Where they come from |
|---|---|---|
| Chart-level FAUST | Title, Subtitle, Footnote |
presentation.grapher_config.{title, subtitle, note} |
| Indicator-level metadata | description_short, description_key |
top-level VariableMeta fields |
Never report Axis titles or Units in the default output (keep the report skimmable). Never include description_processing.
Inputs supported
| Input kind | Example | Source of per-entity text |
|---|---|---|
| MDim export | wb/latest/incomes_pip#incomes_pip |
export/multidim/<ns>/<ver>/<name>/<name>.config.json, plus grapher-channel inheritance for each view's primary y indicator |
| Grapher/garden dataset | data/grapher/wb/2026-03-24/world_bank_pip |
iterate columns across all tables; all text is [inherited] |
| Hand-picked indicators | grapher/wb/2026-03-24/world_bank_pip/incomes#share__... |
same, filtered to the listed columns |
Always load indicator metadata from the GRAPHER channel, not garden. The grapher channel flattens dimensional indicators into one column per combination and renders the Jinja metadata templates with those specific dimension values — that's what Grapher actually shows.
Required output format
# <mdim_name or dataset_name> — <top title>
**Preview:** [<catalog_path>](<admin_url>)
Total views: **N** (for MDims)
## How to read this file
- [override], [inherited], [missing] explanation
## Description-key bullet legend
- **<slug>** — <full bullet text> (one row per unique bullet)
## <view or indicator heading — uses chart Title when resolvable>
**<Dim name>:** <Choice name> · **<Dim name>:** ... (human-readable dims)
**Preview:** [...](...) (view-level link)
- **Title** [source] ...
- **Subtitle** [source] ...
- **Footnote** [source] ...
- **description_short** [source] ...
- **description_key** [source]
- slug-1
- slug-2
Key implementation features (all required)
- Grapher-channel metadata loading:
Dataset(data/grapher/<ns>/<ver>/<ds>).read(<table>, safe_types=False)[<col>].metadata.
1a. description_key arrives as a markdown STRING, not a list: the grapher channel serializes it via owid.catalog.core.meta.description_key_to_string — multiple bullets become one string joined as "- b1\n- b2\n…", a single bullet becomes plain prose (datasets built before the change still carry lists). scripts/_common.py:description_key_as_list() normalizes both forms back into a bullet list; both report modes route through it. The same trap hits MDim step code that asserts/replaces bullets from tb[col].metadata.description_key: OLD_TEXT in list(dk) silently iterates characters on the string form and the assertion fails (or, worse, a for b in dk loop explodes bullets into characters). Normalize first (see _description_key_bullets in incomes_pip.py / gini_lis.py / gini_wid.py), then do list-membership asserts and per-bullet swaps; setting either a list or a markdown string back on view.metadata["description_key"] is accepted (Collection converts lists via _convert_description_key_lists).
-
Rebuilding the MDim
.config.json: useetlr export://multidim/<ns>/<ver>/<name> --export --private. This runsCollection.save()(validate_indicators_in_db+save_config_local+upsert_to_db— admin-API upsert, not a big data push). If the command errors with a MySQL connection-refused trace, surface that to the user and stop — don't monkey-patch around it. -
Description-key dedup with auto slugs: collect unique bullets into a per-file legend, auto-generate a short slug from the first ~3 non-stopword content words of each bullet (kebab-case), disambiguate collisions with
-2/-3suffixes. Each view references bullets by their slugs, rendered as sub-bullets. -
Dimension collapse (MDim only): accept a
collapse_dims: list[str]per MDim. Group views whose non-collapsed dims match, render one section per group, show variant previews on separate links labelled by the collapsed dim's value. -
Placeholder parametrization: when the Title / Subtitle / description_short / description_key vary across collapsed variants only by a simple substitution, collapse the text to a single
{dim}placeholder. Try the raw value first (dayinper day), then snake → space (before_tax→before tax), then snake → hyphen (before-tax); case-insensitive regex. If all variants collapse to the same placeholder-bearing string, use it; else fall back to sub-bullets. -
Global placeholder legend: when one or more dims are parametrized, include a header line listing
`{dim}` ∈ {val1, val2, ...}once at the top of the file. -
Human-readable dim selections subheader: directly under each view heading, render the dim selections using the dimension
nameand choicenamefrom the MDim config. Filter outnansentinel values.
7a. Heading disambiguation when views share a title: when two or more groups collapse to the same ## <Title> heading, append (Dim name: Choice name) built from the non-collapsed dim(s) whose values differ across the colliding groups. Dim order follows the MDim config; only the differentiating dim(s) are appended.
-
Preview URLs: main MDim URL is
https://admin.owid.io/admin/grapher/<urlquote(catalog_path)>. Per-view URL appends?dim1=slug1&dim2=slug2from the view'sdimensionsdict. -
Override / inherited / missing tagging:
[override]= text explicitly set on the view (MDimconfig.*ormetadata.*);[inherited]= resolved from the primary y-indicator's ETL metadata;[missing]= absent in both. For grapher-dataset and indicator-list inputs, every tag is[inherited]or[missing]. -
ai/directory output (per project convention). One Markdown file per entity the user asked about.
Dump-mode workflow
- Confirm the input kind: one MDim, several MDims, a dataset's indicators, or a hand-picked list.
- For MDim input, confirm which dimensions (if any) to collapse —
periodis a classic candidate. - For MDims, rebuild the
.config.jsonexports usingetlr(full ETL path). For grapher/garden input, rely on the already-built dataset folder. - Run the appropriate script:
- MDim config rebuild:
.venv/bin/etlr export://multidim/wb/latest/incomes_pip --export --private - MDim mode — edit the
MDIMSlist at the top ofscripts/generate_mdim_text_report.pyor pass--config <json>:.venv/bin/python .claude/skills/edit-faust-metadata/scripts/generate_mdim_text_report.py - Dataset mode:
.venv/bin/python .claude/skills/edit-faust-metadata/scripts/grapher_dataset_mode.py \ --dataset data/grapher/wb/2026-03-24/world_bank_pip - Indicator-list mode:
.venv/bin/python .claude/skills/edit-faust-metadata/scripts/grapher_dataset_mode.py \ --indicators 'grapher/wb/2026-03-24/world_bank_pip/incomes#thr__...' \ 'grapher/wb/2026-03-24/world_bank_pip/incomes#share__...'
- MDim config rebuild:
- Show the user the output file paths and wait for feedback — the user almost always wants iterative tweaks to format. Dataset mode has no collapse/parametrization; if the user wants dataset views grouped by a shared dim, fall back to the MDim-style code path.
Comparing the live config to a target FAUST report
A common workflow: the user shares a FAUST report that represents the desired end state (their edited copy of an earlier auto-generated report) and asks "does the live MDim match this?". Treat the report as the source of truth by default — when the live config differs, the fix lands in the metadata to make the live match the report.
Two cases warrant a confirmation before silently editing the metadata to match:
- Text-content drift in inherited bullets. If the report shows older / shorter wording while the live config has newer longer wording, surface the diff side-by-side and confirm before reverting — sometimes the user rewrote the definition after generating the report and the live config is the up-to-date target.
- View-count mismatch. If the report has more or fewer sections than the live config, list the missing/extra sections explicitly and confirm before adding/removing views.
Before doing the field-by-field comparison, refresh everything the live config depends on. Skipping a step leaves a stale catalog, which produces phantom drift that isn't real:
.venv/bin/etlr garden/<ns>/<ver>/<ds> grapher/<ns>/<ver>/<ds> --private --force --only
.venv/bin/etlr multidim/<ns>/<ver>/<mdim> --export --only --private --force
Run both upstream steps — garden --only alone does NOT refresh the grapher channel, and the FAUST scripts read from grapher, not garden.
Then audit:
- Spot-check several view types, not just one — overrides,
before_vs_after, single-decile, all-decile (multi-indicator), share-vs-non-share. Different code paths populate different fields. - Override fields live on the view; inherited fields don't. A view's
metadata.description_keyin the.config.jsononly contains bullets the MDim explicitly set. Empty array / missing key means the bullets come from the underlying y-indicator — read those viaDataset(<grapher_path>).read(<table>, load_data=False)[<col>].metadata.description_key. - Programmatic display.name overrides on indicators within multi-indicator views live on
view['indicators']['y'][i]['display']['name'], not on the view's text fields. Inspect them per-indicator. - Slug collisions in the report (
Income-share-decilevsincome-share-decile) are tooling artefacts — ignore capital/lowercase slug differences during audits. - Check punctuation around markdown links specifically.
[Economic Inequality.](url)(period inside) vs[Economic Inequality](url).(period outside) is a common copy-edit issue and easy to miss. - Common drift you'll see: hyphenation removed from welfare_type bullets; qualifiers removed from subtitle / description_short overrides;
description_key[1:]drops removed; new indicator-specific bullets added. - If the live and target diverge, the fix usually lands in one of three places: the garden meta.yml
definitions.description_key_*blocks (text content); the MDim.py(override via_assert_and_replace,_replace_welfare_type_bullet, orview.metadata[...] = ...); rarely, the indicator'spresentation.grapher_configblock. - After every fix push, re-run garden + grapher + MDim export and re-verify against the report.
Target-driven description_key restructuring across sibling MDims
A recurring large-scale workflow: the user pastes an edited FAUST report as the desired state for one dataset's MDims, then repeats it for sibling datasets ("now do the same for LIS / WID"). Lessons that generalize:
- The delta concentrates in
description_key. Chart-level FAUST and description_short almost always already match the target — verify that first and scope the work to bullet texts, per-variable list ordering, and the MDim mirror constants. Apply the new bullet ordering to the whole dataset, not just the MDim's indicators, unless told otherwise. - Audit the target's legend↔views cross-references before editing. Slugs referenced by views but missing from the legend usually map to an existing garden bullet — keep it unchanged. Legend bullets referenced by no view get skipped (confirm once with the user).
- Fact-check target texts against each dataset's actual data — hand-edited targets propagate copy-paste from the first dataset: "income or consumption" onto income-only datasets, "country or region" where no regional aggregates exist. Verify empirically (count non-null values for region entities per indicator family) rather than trusting either the target or the old metadata.
- Bullets describing UI affordances must match the view's actual UI. A bullet like "this chart gives the option to show breaks" is wrong on grouped views that exist only for one choice of that dimension — drop the bullet or strip the affordance sentence via a view-level override. Beware MDims that keep their own config-level
definitions.description_key_*overrides — garden edits don't reach those views; align the config-local copies separately. - New
#dod:…links in a target may not exist. Check thedodstable via public Datasette before shipping; if missing, keep the link + list it in the PR body as a "create in admin" follow-up. - Shared definitions serve more variants than the target shows. Add Jinja branches so the target's wording doesn't leak onto other variants (poverty vs inequality, wealth vs income), and check the untouched-variant MDims in the regenerated reports.
- Jinja dimension comparisons: match the value type used elsewhere in the same file. Dimension values can be int in one dataset and str in another — a wrong-type comparison renders the else-branch silently; copy the comparison form from a working definition and spot-check the affected view.
- Bulk list edits with Edit/replace_all: order by containment. Reorder the anchored/longer per-variable blocks first and the bare short-tail patterns last; then verify all lists at once with a small parser script over the meta.yml.
- Mirror constants change in lockstep. Every garden text edit needs the matching
OLD_*/NEW_*constant edit in the MDim.py; the rebuild's assertion pass is the drift check. - Tag placement is cosmetic in target comparisons: a view the target marks
[inherited]may only be implementable as[override](and vice versa) — identical bullet content is what matters.
Regression diff: prove a refactor didn't change user-facing text
When you change an MDim .py (reorder indicators, flip a choice order, change which y-indicator is primary) and need to prove the rendered FAUST is unchanged except for the intended diff, diff two auto-generated reports instead of eyeballing one. This is the right check whenever a change shifts the primary y-indicator (y[0]), because that's what drives inheritance.
config_path accepts any JSON path, not just the live export/multidim/.../<name>.config.json — so point two runs at two config snapshots:
- Build the baseline config (e.g.
git checkout origin/master -- <step>.py && etlr <mdim> --export --grapher) and copy its<name>.config.jsonto/tmp/cfg_before/. Restore your branch, rebuild, copy to/tmp/cfg_after/. (Note:git checkout … -- a.py b.pywon't word-split an unquoted$filesvar in zsh — pass the paths literally or use an array.) - Run the report against each snapshot:
echo '[{"name":"gini_lis_BEFORE","config_path":"/tmp/cfg_before/gini_lis.json","collapse_dims":[]}]' > /tmp/fb.json echo '[{"name":"gini_lis_AFTER","config_path":"/tmp/cfg_after/gini_lis.json","collapse_dims":[]}]' > /tmp/fa.json .venv/bin/python .claude/skills/edit-faust-metadata/scripts/generate_mdim_text_report.py --config /tmp/fb.json .venv/bin/python .claude/skills/edit-faust-metadata/scripts/generate_mdim_text_report.py --config /tmp/fa.json - Diff, stripping the BEFORE/AFTER name token:
diff <(sed 's/BEFORE//g' ai/gini_lis_BEFORE.md) <(sed 's/AFTER//g' ai/gini_lis_AFTER.md). Byte-identical = all six fields render the same.
The FAUST diff only covers user-facing text. It will NOT catch indicator-order-only changes (e.g. a Dumbbell arrow direction or a series-color swap that follows column order) — pair it with a structural diff of the two .config.json files when order matters.
Things to avoid
- Do NOT fall back to
title/title_public/display.name/description_shortwhen resolving chart Title / Subtitle / Footnote. Usegrapher_configonly. - Do NOT report
description_processingin dump mode; the user explicitly doesn't care about it for FAUST review. - Do NOT load metadata from the garden channel; it exposes pre-template Jinja text and unflattened dimensions. Always use the grapher channel.
- Do NOT judge "explicitly set at chart level" from the rendered config — only the authored layer (
patchConfigId) distinguishes overrides from inherited values. - Do NOT write new text inline under a variable in a
.meta.ymlwhose text lives indefinitions:, and do NOT add a definition that near-duplicates one the file already has — check for an existing key first, and blast-radius it before reusing its name. - Do NOT write to production — no
admin.owid.iowrites, no prod DB writes, ever. All chart edits go to the branch's staging server and ride chart-diff to production. - Do NOT call
AdminAPI.put_grapher_configorput_mdim_configby hand — the ETL files are the source of truth and the next rebuild overwrites DB-side edits. - Do NOT hand-build
staging-site-<branch>hostnames — useget_container_name/OWIDEnv.from_staging. - Do NOT monkey-patch around a MySQL outage by calling
Collection.save_config_local()directly or stubbing outvalidate_indicators_in_db/upsert_to_db. If MySQL is down, stop and tell the user. - Do NOT produce HTML
<details>blocks or tables in dump-mode reports — the preferred format is a flat Markdown outline with bullet fields. - Do NOT suggest dump + compare mode — only enter it on explicit request.
Related memories, skills, and references
.claude/projects/-Users-parriagadap-etl/memory/faust_definition.md— FAUST = Footnote, Axis titles, Units, Subtitle, Title..claude/projects/-Users-parriagadap-etl/memory/feedback_chart_faust_inheritance.md— the inheritance rule, with the caveat aboutgrapher_confignot being universally populated..claude/skills/owid-metadata-generation/SKILL.md— writing style rules + the canonical metadata-check suite..claude/skills/pr-babysitter/SKILL.md— the Codex review→fix→resolve background loop (step 11)..claude/skills/check-chart-preview/SKILL.md— visual QA on staging..claude/skills/check-empty-entities/SKILL.md— entity-availability lookups for selection edits.apps/chart_sync/admin_api.py— the AdminAPI client the scripts build on.