fontina0.2.0

Next: 0009 — fontina reads the sfnt family, and says so when it will not read a file,  Previous: 0007 — Project license: GPL-3.0-or-later,  Up: Architecture decision records

0008 — The CLI is the plugin surface, and here is what it promises

Status: accepted, 2026-09-05.

Context

fontina has no plugin API and is not going to grow one. Every command prints machine-readable output with --json, every type it prints has a definition in schemas/cli-output.json, CI diffs that schema on every change, and since M3 every command that takes faces reads them from standard input as well. A program can already read fontina, filter, and write back:

fontina list --json --free | jq '[.[] | select(.variable)]' | fontina tag add variable -

That is a plugin surface. It is also, right now, an accident: it describes what the program happens to do today rather than what anyone building on it may rely on tomorrow. Somebody who writes a script against check ids, or a tool that reads FaceSummary.freedom, has no way to know which of those is a promise and which is an implementation detail that will move in the next release.

An in-process plugin API — a dynamic library, a scripting runtime, a WASM host — was the obvious alternative and is rejected in its own section below. This ADR is the cheaper half of the same goal: write down what the existing surface guarantees, so that building on it is a decision rather than a gamble.

Decision

The command-line interface is the extension interface. These things are stable, and changing any of them is a breaking change requiring a major version:

  1. Face ids are stable within an index. A face keeps its id across a rescan of the same file (carry_over_* in index/library.rs exists for this). An id is not portable between indexes; the identity hash is what carries a face between machines, which is why the collection export is built on it and not on ids.

  2. Health-check ids (area/check, e.g. license/nonfree, fvar/axis-range). An id is never renamed and never re-pointed at a different condition. A check whose meaning changes gets a new id and the old one is retired. CLAUDE.md has said this since M1; this ADR makes it a promise to people outside the repository, not only a rule inside it.

  3. JSON field names and their types, for every type in schemas/cli-output.json and schemas/collection.json. A field is never renamed, never given a different type, and never repurposed.

  4. Exit codes. 0 success. 1 an error, with a message on stderr. 2 a conflict that stopped the operation — activate and conflicts use it, and it is deliberately distinct from 1 so a script can tell “this font clashes with one already active” from “something went wrong”. check uses 1 for a failing check, which is an error about the font rather than about fontina.

  5. --json on stdout, prose on stderr. A pipeline reading stdout never has to filter progress or warnings out of it. --json output is a single value per invocation, except watch --json, which is one object per line by design.

  6. Reading targets from -, in both shapes documented in the manual: fontina’s own --json output, and one target per line.

These things may be added, never removed:

These things are not promised:

Where a plugin’s own state goes: nowhere in fontina. Tags and collections are the places a program is expected to write, and both are first-class, exportable and documented. A tool that needs storage of its own keeps it in its own file.

Consequences

Alternatives considered