Edit FAUST & metadata
SkillDatabases & dataEdit user-facing chart and indicator text (FAUST — Title, Subtitle, Footnote — plus description_short, description_key, units, display.name, attribution_short, entity selection, and any other user-facing metadata) from a conversational request in the terminal. Accepts a chart or MDim referenced by live link, staging preview link, admin link, bare slug, chart id, or indicator catalogPath. Routes each edit to the right layer (garden .meta.yml, MDim yaml/py, or chart config via the admin API — ALWAYS on staging, never production), reports the blast radius on other charts/MDim views/explorers before applying shared-metadata changes, and ships via a PR with an automated @codex review loop. Trigger on "change the subtitle of <link> to …", "fix the footnote on this MDim view", "edit the units / description_key / selected countries of …", or any pasted grapher/staging/admin link plus an edit request. Also covers the legacy audit mode ("dump/audit the FAUST for dataset X", "review the text of all views in this MDim") — a Markdown dump + compare workflow for massive changes, entered ONLY on explicit request.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Edit FAUST & metadata skill
What this skill tells your AI
The instructions your AI receives, as published by owid/etl in .claude/skills/edit-faust-metadata/SKILL.md and read by ahel’s review.
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 chart/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 viz://chart/<ns>/<ver>/<name> --grapher. - (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 chart): 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 chart 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.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 156
- Forks
- 30
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
edit-faust-metadata- Source
- github.com/owid/etl