Skip to content

ADR-0028: YAML as the structure profile file format, hand-validated against a published JSON Schema

Status: Accepted Date: 2026-08-31 Deciders: houfu

ADR-0006 decided that readers are driven by a declarative structure profile, and named the concrete file format a genuine, undecided design task on the critical path (ROADMAP § 6: “could be a week of argument”; PRD § design questions, tracked as #100). R1f turns that design task into a testable requirement: the format must be flat, plainly named and commented, with a published schema, such that a model given the schema and one worked example can write a valid profile for a new document family in a single turn. R1e requires the same profile to be loadable from a file, from a mapping passed at call time, from the CLI, from the MCP tools, and by pasting on the site.

Two other accepted decisions bound the choice:

  • ADR-0004 keeps a stdlib core; built-in profiles ship in core (ROADMAP § M1), so whatever parses the on-disk format is not an optional extra the way [docx] or [pdf] are — it is a load-bearing dependency of every install.
  • ADR-0023 rejects pydantic for the block and change models in favour of frozen dataclasses plus a hand-maintained JSON Schema, “something the stdlib plus a hand-maintained JSON Schema can do.” The same reasoning applies to profiles.

This ADR is scoped to what a profile looks like when someone writes one — a naming and validation question, not an adoption question. Most projects are expected to never write a profile at all: Redlines(a, b, profile="contract") selects a built-in by name (ROADMAP § M1, tracked separately as #101), and 1.1’s auto-selection removes even that choice. A profile is only authored by hand when a document family none of the built-ins fit well needs one, per ADR-0006’s “short enough for a person to write for their own precedent bank in half an hour.” A file specifically is rarer still — most authoring happens as a Python mapping or pasted text (see Decision).

Prior art. A structure profile is not a settings file — it is an ordered list of named pattern rules, each a pattern plus metadata about what to do with a match. That is a narrower and more specific category than “how Python tools take configuration,” and the two lead in different directions:

  • General Python tool settings — ruff, black, mypy, pytest, coverage.py, isort — have converged on TOML via pyproject.toml. This convention is real, but it answers the wrong question: those are flat key-value settings, not lists of named pattern rules.
  • The closer structural analog is a file that holds a list of named rules, each a pattern plus metadata about what to do with a match. Every real example found in that category uses YAML or XML, not TOML. Semgrep rules are rules: [{id, pattern, message, severity, languages}] in YAML. Vale — a prose linter, the closest domain analog to “recognise a pattern in document text and assign it meaning” — ships its built-in styles as YAML files with a rule-kind (extends: existence, extends: substitution, …) plus type-specific fields (tokens, swap); a real example, errata-ai/Google’s WordList.yml, is structurally close to what a redlines span_extractors entry needs. LanguageTool’s custom grammar rules are XML. No tool surveyed in this category uses TOML.
  • textlint’s .textlintrc is not a counter-example: it holds only enabled-rule names and small option objects, not pattern content — the patterns live in separately-installed JS packages. That shape matches redlines picking a built-in profile by name, not authoring one, so it does not bear on this decision.
  • EditorConfig’s bespoke INI-like format is a real precedent for “flat, custom, no comment-poor JSON,” but it predates TOML’s existence (2011) and was built to be parseable by the widest possible range of host languages — a constraint redlines does not share, since every consumer here (core, CLI, MCP server, site) is Python or talks to Python over MCP.
  • YAML’s dependency cost turns out to be small: PyYAML ships a pure-Python implementation, the LibYAML C bindings are opt-in and gracefully absent otherwise, and PyYAML installs via micropip in Pyodide from its pure-Python wheel. It does not threaten the Pyodide build (ADR-0019).

Where a profile is written down as a file, that file is YAML, parsed with PyYAML (yaml.safe_load, never the unsafe loader) — a normal, non-optional core dependency. A file is one of three equally-supported ways in, not the default one: see the three entry points below, and no built-in profile requires a caller to touch a file, a schema, or YAML syntax — selecting one by name is enough.

A profile is a flat mapping of top-level keys, most of them a list of small, self-contained records, mirroring ADR-0006’s list of what a profile declares and Vale/Semgrep’s “list of named rules” shape:

  • name, description — identity.
  • label_patterns — an ordered list of records (name, pattern, style, depth_mode); order is precedence.
  • heading_resets — a list of records naming headings that clear the numbering stack.
  • heading_rule — one mapping of generic heading heuristics.
  • role_rules — a list of records assigning a semantic role via one of three match kinds: heading, ancestor_heading, parent_role.
  • span_extractors — a list of records, each a regex plus which capturing group is the span’s text.

Order carries meaning, and it does not carry the same meaning in all three lists — a decision made here rather than left for each reader to invent, because it is what a reader implementer hits first. A block carries one label and one role but many spans, so: label_patterns and role_rules are tried in order and the first match wins; span_extractors are all run and every match kept, with order fixing only the sequence spans are emitted in. The schema says this in the description of each of the three arrays, so a profile author reads it where they are working rather than in an ADR.

One case needed deciding beyond that, because list order alone gives the wrong answer: a block can sit under two headings that both match an ancestor_heading rule — a “Conclusion” section nested inside “My decision” — and resolving that purely by list order gives the paragraphs the outer role, whichever way the rules are written. Proximity beats list order: ancestor_heading resolves from the block outwards, the nearest matching ancestor decides, and list order only breaks a tie between rules matching that same heading. The alternative, pure list order, would force authors to hand-order rules innermost-first and would leave “nearest wins” inexpressible; this way the common case needs no thought and the escape hatch is still there.

Every field name is plain English, every list entry is a flat one-level mapping (no nesting inside a rule), and YAML’s # comments let a hand-written profile explain each pattern in place — the worked example in tests/profiles/example_contract.yaml is deliberately commented this way, in the same style as the Vale/Semgrep examples above. Regex patterns are always written as single-quoted YAML scalars ('...'), which take backslashes literally with no escaping, avoiding the double-backslash problem double-quoted YAML or JSON strings would otherwise create for every pattern. A pattern containing an apostrophe doubles it ('parties'' case') rather than switching to double quotes, which would reintroduce backslash-doubling for that one pattern and break the file’s consistency.

Validation is hand-rolled Python (redlines/profiles/loader.py), not the jsonschema package: it walks the parsed mapping, checks required fields, enums and types, compiles every regex (catching re.error), and collects every problem found rather than stopping at the first, so a person or a model fixing a profile can address them all in one pass. A published JSON Schema (redlines/profiles/schema.json, same draft-07 convention as the existing redlines/json_schema.json) documents the same shape for humans, models (as an MCP resource, ADR-0018) and any external tooling, mirroring ADR-0023’s “hand-maintained JSON Schema” approach rather than generating one from the dataclasses or validating against it at runtime. It ships inside the wheel and is read through profile_schema_text(), so the separate MCP server package (ADR-0017) serves it through an API rather than a path into redlines’ directory layout. Hand-maintaining it against the validator is a real cost, so it is not maintained on trust: a drift test checks the schema’s key sets against the loader’s, its enums and defaults against the dataclasses, and each required list by dropping that field and asserting the loader rejects it.

Three entry points cover R1e: load_profile(source) accepts a Mapping (used as-is), a Path (read from disk), or a str, which is sniffed — an existing filename is read from disk, anything else is parsed as YAML text directly. parse_profile_yaml(text) and profile_from_mapping(mapping) are the two unambiguous primitives underneath, for callers that already know which case they hold: an MCP tool or the site’s paste box handles a pasted profile as text it received, not as something that might coincidentally name a file already on the server’s disk, so those call parse_profile_yaml directly rather than relying on load_profile’s sniffing.

The mapping form is the common validated core: a .yaml file, raw pasted YAML text and a Python dict built by a caller all pass through the same profile_from_mapping validation, so nothing loaded from disk is checked more strictly than something built in memory.

This issue ships the format, the loader and the validator only. The built-in generic, contract and markdown profiles (ROADMAP § M1) are a separate, later issue; tests/profiles/example_contract.yaml is a worked example proving the format’s legibility, not a shipped built-in.

TOML. Matches Python’s own tooling convention (pyproject.toml) and costs a smaller dependency against ADR-0004’s stdlib-core stance — nothing on 3.11+, a tiny tomli backport on 3.10. Rejected because that convention is for settings files, not the rule/pattern-file category a structure profile actually belongs to: no tool surveyed in that category uses TOML, while Semgrep and Vale use YAML for something structurally very close to a redlines profile. TOML’s array-of-tables syntax is also measurably more verbose than YAML’s list-of-mappings for this shape — a [[label_patterns]] header repeated per entry versus a single label_patterns: key with --prefixed items — which cuts against R1f’s single-turn-authoring goal.

JSON with description keys instead of real comments. Zero new dependency, and it is the existing convention in redlines/json_schema.json. Rejected as the authoring format: no comment syntax, and every regex needs its backslashes doubled (\\d instead of \d), a worse single-turn authoring experience than YAML’s single-quoted literal scalars. Kept anyway, in spirit: redlines/profiles/schema.json is exactly this JSON-with-descriptions convention, used for the schema artifact rather than the profile itself.

XML, per LanguageTool’s precedent. Rejected: no stdlib-friendly, safe, easy-to-hand-write XML story in Python that beats YAML on any axis that matters here, and it is a poor fit for “flat and plainly named” — XML’s own idioms (attributes vs. elements, namespaces) invite exactly the ambiguity R1f is trying to avoid.

A validation library (jsonschema) driven by the published schema. Rejected for the same reason ADR-0023 rejected pydantic: it is a dependency for something straightforward Python code can do, and it would produce generic, less actionable error messages than field-path-aware hand-written checks.

Deeply nested mappings mirroring a hierarchy (e.g. one mapping per label style, nesting role rules under it). Rejected: it reads naturally to a human but is exactly what ADR-0018 warns is “deeply nested or clever” and rules out. Flat lists of records, tried in order, are less elegant but keep every rule’s precedence explicit and each entry self-contained.

Positive: the profile format matches its closest real-world precedent (Semgrep, Vale) rather than a superficially similar but differently-shaped convention (Python tool settings). YAML’s list-of-mappings syntax is more compact than TOML’s array-of-tables for this shape, and single-quoted scalars sidestep the backslash-escaping tax JSON would impose on every regex. The three loading entry points map directly onto R1e’s four consumers (file, mapping, CLI, MCP/site) without the CLI or MCP server needing any format-specific code of their own. Every validation failure is reported, with a field path, in one pass.

R1f was tested rather than asserted, twice. On 2026-08-31 a model was given only schema.json and tests/profiles/example_contract.yaml and asked, in one turn, for a profile for Singapore court judgments — a family neither example covers, with bare paragraph numbers, catchwords, neutral citations and internal paragraph cross-references. It produced a profile that validated first time, which is the evidence for R1f. More usefully it reported eleven things it had to guess at, and most were the schema under-describing its own vocabulary rather than the shape being wrong: what word means as a label style, whether role is open or closed, whether parent_role means the block tree’s parent or the heading above, what text a heading pattern is matched against, the regex dialect, and how to quote a pattern containing an apostrophe. Those were answered in the description fields, where an author reads them, and the exercise was then repeated unchanged: seven of the eleven stopped being questions. That before-and-after is the reason to repeat it whenever the descriptions change — the schema’s prose is the half of R1f no test can check.

The second run’s remaining findings were about capability, not wording, and are answered deliberately. A profile cannot say “a bare number is only a label if it continues the run” — there is no sequence-awareness in the format, only patterns — so a paragraph opening “2019 saw…” will be mis-labelled by a bare-number pattern. That belongs to the reader (#102), which sees the whole document and can score a candidate against the numbering run, and it is exactly what matched_by and per-block confidence (R1d, R3) exist to report. The format likewise says nothing about two span extractors matching overlapping ranges — the semantic pass’s job (#104) — and a span stays a typed range of text rather than a structured record, so a citation is captured as written rather than split into provision and instrument. heading_rule’s four fields narrow the candidates rather than constituting the whole heading scorer, which is why sentence-case headings are not a flag: the reader weighs signals a profile does not control, and the field set should only grow once #102 exists and a new field can be measured rather than guessed.

One capability gap is worth naming rather than explaining away: all three match kinds are structural, keying off headings or an already-assigned parent role, so a rule cannot look at the block’s own text. A profile therefore cannot say “an indented block quotation is a quote”, even though quote is in ADR-0005’s recommended vocabulary. A fourth text match kind would close it symmetrically, and was considered and deferred: the match-kind vocabulary is closed and far easier to grow later than to shrink, and quote and code are arguably recognised from formatting the reader sees and the profile does not. Revisit at #104, where the semantic pass either wants it or demonstrates it does not.

Negative: PyYAML becomes a new hard core dependency (previously only click, click-default-group, rich-click, rich) — a real departure from ADR-0004’s stated stdlib-core stance, since built-in profiles ship in core and nothing can gate that import behind an extra; its cost is mitigated by PyYAML’s pure-Python, Pyodide-safe wheel, but it is still one more thing every install pulls in. Hand-rolled validation is bespoke code with its own tests to keep in sync with schema.json, exactly the tax ADR-0023 already accepted for the change-tree schema. The load_profile sniffing between “path” and “raw text” is inherently a little ambiguous for str input; the mitigation is that callers handling untrusted pasted text are documented to bypass it. Safe YAML loading is mandatory throughout — the default yaml.load can construct arbitrary Python objects from tags in the input, which would be a real risk given profiles are meant to be pasted from an MCP tool or a chat model. The loader used is a SafeLoader subclass that additionally rejects duplicate mapping keys, because plain YAML resolves a repeated pattern: to the last one silently: tolerable in a settings file, but in a file of match rules it lets a profile behave differently from the way it reads, with the losing line still visible in the document.

A profile is nonetheless trusted input, and the format cannot make it otherwise. Its patterns are regular expressions that a reader runs against document text under Python’s backtracking re engine, where a short valid pattern such as (a+)+$ takes time exponential in the length of the subject — measured here at 0.01s over 18 characters and 2.7s over 26. Validation compiles every pattern, which is cheap and catches syntax errors, but compiling is not evidence of termination and the standard library offers no way to bound a match once it has started. The alternatives were considered and rejected for 1.0: a linear-time engine (re2) is a compiled dependency, which ADR-0004’s stdlib core and ADR-0019’s Pyodide target both rule out; static rejection of nested quantifiers is a heuristic that both misses real attacks and rejects legitimate patterns, and would buy a false sense of safety. So the boundary is stated instead of pretended: a profile has the standing of a Vale or Semgrep rule file, and a deployment that accepts one from the public — the site’s paste box, an untrusted model — needs a killable subprocess or worker around whatever runs the patterns. Nothing in this issue matches, so nothing here is exposed yet; the enforcement point is the reader (#102), which is the first code to run a profile’s patterns against text and so the first place a bound can be applied. The risk this ADR is written to guard against, and that its docstrings and the ADR-0006 built-in-profile rollout (#101) both need to keep guarding against, is documentation and examples leading with the file-authoring path and leaving the impression that using redlines on a new project means writing a profile — it does not; it means picking one of the built-ins by name, the same as today’s Redlines(a, b).

If the MCP profile-authoring loop (ADR-0018, draft_profile/refine_profile) shows in practice that models produce malformed YAML syntax more often than malformed JSON or TOML would have — YAML’s indentation sensitivity and its many implicit scalar conversions (no, on, dates) are its well-known failure modes — reconsider the file format specifically; the validated mapping shape underneath would not need to change. The drift test already covers the mechanical half of hand-maintaining schema.json (key sets, enums, defaults, required fields), but not the description prose, which is the half R1f actually depends on and the half a test cannot check. If a repeat of the R1f exercise shows models guessing at things the descriptions were supposed to have settled, that is the signal to generate the schema from the dataclasses instead of hand-writing it, per ADR-0023’s own revisit condition for the change-tree schema. If the site or the MCP server ever needs to run a profile it did not get from the user’s own machine, the trust boundary above stops being a documentation matter and needs a real mechanism — revisit then, and prefer bounding the reader (a killable worker, a size cap on the text a pattern is run against) over trying to prove patterns safe. If a pure-Python linear-time engine appears that survives ADR-0004 and the Pyodide build, that changes the calculation too.

If PyYAML’s dependency cost turns out to matter in practice (an install-size complaint, a Pyodide bundle-size problem), reconsider against ruamel.yaml or a narrower hand-rolled YAML-subset parser before reconsidering the format itself.

There is deliberately no composition mechanism — no extends:, no way for one profile to inherit or override another’s rules. A profile is meant to be short enough to read whole, and inheritance would make “which rule actually applied here?” a question you cannot answer by reading one file. Revisit at #101: building generic, contract and markdown side by side is the first point where real duplication between profiles would show up, and if two of the three share most of their span_extractors verbatim, that is the evidence for adding composition — not before.

ADR-0004, ADR-0005, ADR-0006, ADR-0018, ADR-0023.