# Edit Faust Metadata

> Edit 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")

- Skill: `owid/edit-faust-metadata` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add owid/edit-faust-metadata`
- Raw SKILL.md: https://api.skillmd.com/api/skills/owid/edit-faust-metadata/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: owid (https://skillmd.com/u/owid)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/owid/edit-faust-metadata

---


# 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](#dump--compare-mode-explicit-request-only).

## 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 check `multi_dim_data_pages` after `charts`.
- 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.yml` exists 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_configs` row named by `patchConfigId`). 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> --grapher` to upsert to staging. **Check for a `<short_name>.meta.override.yml` next 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:**

1. 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.name` via **route (c)** — offer both, default to fixing the source.
2. 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)**.
3. 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-entities` skill's availability lookup), and keep `selectedEntityColors` in 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 as `check-empty-entities`' fix guidance — keep the two in sync.

**Target = MDim view, field F:**

1. Overridden at view level (`config.*` for chart fields, `metadata.*` for indicator fields in the yaml, or programmatic writes in the `.py` — grep for `view.metadata[...]`, `_assert_and_replace`, `_replace_*`) → **route (b)**. Mind mirror constants: MDim `.py` files hard-copy garden bullet texts under `OLD_*`/`NEW_*` assertions — every garden text edit needs the matching constant edit; grep the repo for fragments of any text you change.
2. 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.
3. Never write `multi_dim_x_chart_configs` or 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](#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.yml` declares its sentences as `definitions:` 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.yml` with 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_estimates` should 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 under `description_short` and 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. Expanding `description_short` is 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 in [`owid-metadata-generation`](../owid-metadata-generation/SKILL.md).
- **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 `.py` constants). Search a few **distinctive 5–8 word fragments** of the text across `etl/steps/` — near-duplicates differ by a word or two, so one long exact-match search finds nothing while three short ones find everything:

  ```bash
  rg -l -i "harmonizes labor statistics from national sources" etl/steps/
  rg -n -i "may not be directly comparable across countries" etl/steps/ | head -30
  ```

  Report 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 identical `tables:` means identical metadata for *every* variable at *every* dimension value. That's both stronger and far cheaper than a garden+grapher rebuild:

  ```python
  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-level `definitions`, 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:

1. **Proceed broadly** — the text is wrong everywhere; fix at the source.
2. **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.
3. **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):

1. **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 fetched `configFull`, which submits the stale full and pins the old text into the child's patch as an override (mechanics in `/update-dataset` step 7's narrative-chart notes). Then verify with a fresh `get_narrative_chart(id)["configFull"]` read and list the child at the checkpoint so the user can eyeball the new text in the narrative framing.
2. **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)` from `apps/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_chart` on 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.

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.

1. Parse the request; run `resolve_target.py --no-db` for instant identification feedback to the user.
2. 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 about `description_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.
3. Wait for staging readiness: retry `OWIDEnv.from_staging(branch).read_sql("SELECT 1")` (builds take a few minutes).
4. Run `resolve_target.py` with the DB; pick the route via the decision tree.
5. Run `blast_radius.py` per the rules above; ask the user if other surfaces are affected.
6. 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 via `parentMultiDimXChartConfigId`), `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's `parent_view` / the view's `mx_id` in the blast-radius `--json` output). 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]`.
7. Reflect on staging **without committing**:
   - route (a): `.venv/bin/etlr garden/<ns>/<ver>/<ds> grapher/<ns>/<ver>/<ds>` then `STAGING=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 viz://chart/<ns>/<ver>/<name> --grapher`.
   - route (c): already live on staging.
8. Run the metadata quality checks scoped to the edit (next section); fix findings and re-run the affected steps.
9. Verify on staging (section after); show the user the preview links.
10. **CHECKPOINT** — show: the `git diff` (routes a/b) and/or the chart-patch JSON diff (route c), staging preview links (strip the `.tail6e23.ts.net` suffix from admin links), the blast-radius summary, and any unresolved check findings. **Wait for the user's explicit go-ahead.**
11. After the go-ahead, hands-off:
    - `make check`;
    - commit `🔨🤖 <description>` with `Co-Authored-By: Claude <model name> <noreply@anthropic.com>`;
    - first push needs the upstream: `git push -u origin <branch>`, then verify `gh pr view --json files` is 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 review` comment, record its exact timestamp, and spawn the `pr-babysitter` skill'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.owners` in the garden `.meta.yml` of 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 with `gh 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-review` scoped 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 a `definitions:` 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](#target-driven-description_key-restructuring-across-sibling-mdims)). 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`](../check-hardcoded-years/SKILL.md)' 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's `minTime`/`maxTime`/`map.time` before 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 the `dods` table 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-preview` skill 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 == 5` vs `decile == "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.io` or the production DB. The guard in `update_chart_config.py` enforces 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-run` stops after the diff.
- Run `--dry-run` first, 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 (supports `collapse_dims` and 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 viz://chart/<ns>/<ver>/<name>` without `--grapher`: the step then writes the local config and skips the DB upsert (and the `grapher://grapher/<dataset>` upserts of its inputs), which is all the report needs. 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 `--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` | `viz/chart/<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)

1. **Grapher-channel metadata loading**: `Dataset

…(truncated)
