Skip to content
Like what we’re building? Star on GitHub

ADR-102: The YAML author twin — a draft, never a truth ​

Status: Accepted (2026-09-23, lot 5 of #1010: PRs #1631, #1664, #1720 and the PR that closes #1584) Relates to: 098-dsl-versioning-and-authoring-surface.md § 4 (the authoring surfaces: a YAML form, "if lot 5 justifies it, is a conversion surface only"), 100-dry-run-at-the-executor-seam.md (the dry run a document is validated under), the operator's arbitrations of lot 5 recorded on #1010 and #1584 (a conversion surface only; the surfaces that read a document)

Context ​

098-dsl-versioning-and-authoring-surface.md allowed a YAML form of the .bot if lot 5 justified it, as a conversion surface only. The authoring probe measured it on 2026-09-22 (dsl-authoring-probe.md): a mid-size model writing the YAML twin did no worse than with the .bot — 4.0 errors at the first draft against 5.3, 2.7 rounds to green against 3.3, 821 lines read against 912, 30 requirements of 30 on both sides. The same measure showed what the twin does not do: it does not remove the lexical class of mistakes, it moves it — a plain YAML value holding : is a mapping, and an edge string's inner quotes nest badly.

What the lot then had to settle is what a .bot.yaml IS to every surface that can meet one: a launcher, a packer, a content hash, the cloud store, validate, fmt, diagram, the catalogue.

Decision ​

  1. A draft, never a truth. x.bot.yaml is a way of writing x.bot; only the .bot is a program. A document is refused by name, before any effect, at every door that would launch or store it: bundle.Detect (by the name, before the disk), runview.Service.Launch (before the queue persists a run), the compile floor runview.compileUnit, a subbot's source: (C305), the HTTP source resolver and the launch handler (error_code: author_document), the bot-source store's Validate, MCP local_run and local_resume, the remote upload, the schedule. One typed sentinel, bundle.ErrAuthorDocument, names the command that writes the .bot.
  2. Never transported. One file-only predicate, bundle.IsDraftEntry, keeps a document out of a .botz, of both content-hash walkers, of an extraction, of a snapshot, of the store's walkers and materialiser and of a bot install. An extraction creates no directory from a directory entry: the tree it writes is the archive's files, what the hash sees, so two archives that hash alike land the same tree in the shared cache slot. A bundle without a twin keeps its exact hash; an older archive carrying one is admitted, the twin left inside.
  3. Read, as the .bot it stands for, by the surfaces that write or inspect a .bot. validate reads it in the unit and the bundle of that .bot, at the document's own positions, --exec included; fmt gives it its canonical YAML form and converts both ways (--to bot|yaml), each conversion proven the same program; diagram draws that .bot; the MCP local_validate reads it. validate and diagram share one construction of a document's unit and one E054, and diagram runs, once the unit is loaded, the very stages the .bot's own diagram runs (runview.CompileLoadedUnit): what refuses the .bot refuses the document. validate also reports on the .bot beside its input: one that is there and is not the program the document describes is a warning (E056), the difference named — another program, or no program at all: a text the parser only recovers on, or one its unit load or its compile refuses on its own account — so a green verdict on a document is never read as a verdict on the program a launcher opens. It judges programs by ONE oracle, the launcher's: each side is loaded as a unit (pkg/dsl/unit, the fragments its import lines name merged in), given the prompts of the bundle the two files share — what the two sides share is read ONCE for the two of them: those prompts (runview.BundlePromptFiles, the read the verdict's own merge runs) and the fragments their import lines name, the bytes the document's own unit read, so a prompts/*.md or a lib/*.bot saved while the verdict is taken is no difference between them, where a read per side makes one; what the COMPILER reads is outside that freeze — a file an {{include}} inside a prompt body names is read once per compile, so one saved between the two compiles can still be named here, and the read to share is the compiler's to give — and compiled, and the two compiles are held to ir.SameProgram, the oracle every conversion is proven with — the construct the two workflows differ by named where both compiled, since the sorted diagnostic codes that oracle compares first are its answer to a transport that carries no workflow and name nothing an author can edit. So an import line is no difference of its own — a fragment that declares nothing, or one declaring what the other file declares in place, is one program with it — and a declaration's rank is none either, bar the ranks the compiled program itself reads, which that oracle reads for us. No list of those ranks is written here: any ordered slice of the IR makes one — a supervisor declaration lands in Supervisors, a use of a group holding an internal edge in Edges — and an enumeration of what the compiler reads, kept beside the compiler, is the model rejected below. A comment, a blank line, an indentation and a spelling are no program difference: whether the two files are byte-for-byte is fmt --to bot --check's question — the canonical form's gate in CI — and E056 says nothing of it. Nor does it say anything where the program question has no subject: a side that compiles to no workflow once its imports are merged AND carries no refusal of its own is a fragment — a file under lib/, a file of prompts or schemas alone, a document carrying no workflow: — describes no program, and their texts remain fmt --to bot --check's; a .bot refused on its own account is named for that refusal first, whatever it compiles to, and a DOCUMENT its own load or compile refuses leaves nothing to hold the .bot to, the verdict naming that refusal at its position while the .bot is the sound side. Which of the two is current the author alone knows, so it leaves valid as it is and its fix: line names the two --force conversions of §5, each replacing its destination whole. Both surfaces name that .bot as the document was named (bot_path beside file, the refusal of a .bot path fmt will not write over included), the absolute path kept internal; a bundle a document's .bot belongs to is named by its directory's base name, as it is for the .bot itself. A manifest export naming the .bot a document stands for is that .bot, written or not.
  4. Spelled into .bot text, read by the one parser. author.Parse spells the YAML into .bot text form by form from the property registry, the parser reads that text, and every position is mapped back onto the document: one grammar, one reading. An edge keeps the .bot's edge line, as a string. author.Write renders an AST as YAML in the registry's order. What YAML reads otherwise than it is written is refused where the converter can see it, never re-spelled: a number YAML reads in another base or with a + sign, a leading 0, an underscore or an exponent (010 — the octal 8, where the .bot reads 10 — 0x10, +3, 01.5, 1e2), a bool spelled otherwise where text is taken (True), a ! YAML drops as a tag before a value of any style. A - is kept where the value is text. The writer spells what the reader takes, and fmt proves it: a document is written only once it reads back as the program. ir.SameProgram remains the oracle wherever a workflow compiles. Where neither side has one, fmt --to yaml serialises each AST as .bot, reads both with the one parser, and compares the span-free readings: the effective profile is kept, Literal.Raw is spelling (01.5/1.5, 010/10), and only prompts and schemas — inline prompts included, all held by name in the compiled program — have no significant rank. Imports, whose rank decides the unit merge, supervisors, uses and every other list keep their rank; every other read-back alteration refuses the conversion before any write.
  5. A rewrite never loses what the author wrote in silence. fmt rewrites a document in place with its YAML comments on the nodes they hang on (#1760), and refuses — the bytes left the author's — the rewrite whose written form would lose one of them, or a text the .bot reads otherwise than it was written (E053); a document's BOM and line endings are kept. It sees the comments yaml.v3 attaches to a value, and reads off the source those yaml.v3 drops: a flow collection's, as yaml.v3's scanner reads them (the one after an opening bracket, the one between a tag and its bracket), a directive line's (%YAML 1.1 # …), and every comment written BELOW the document's last content line, read there by position — around a ... document-end marker, or under the last declaration (#1784). That last content line is the last line of every MULTI-LINE value, a block scalar's body and a quoted value's continuation up to its closing quote included: a # opening a line of one is the author's text, and a document carrying no comment at all is rewritten rather than refused for it. yaml.v3 attaches some of those lines, to the document's foot or to the foot of a node they are indented under — a comment and its twin of the same text written higher up in the document are two comments, each listed where it is — and a rewrite carries them: the document is rewritten, its comments kept. The ones it attaches to nothing are written back off the source: the writer re-emits the explicit markers the author wrote — a --- opening the document, a ... closing it — with the trailing region they sit in, line by line, its blank lines and its own indentation included, and the comments yaml.v3 DID attach from that region are taken off the nodes first, so no line is written twice (#2359). So the comment on a marker's own line, a block the one below the marker displaces off the document's foot, a block BELOW the marker a blank line keeps off that foot, a second block below one, anything written under a commented marker line, all come back where they were written, and a document already in that form comes back byte for byte. One line the writer still has no place for: a %YAML/%TAG directive opens a STREAM rather than describing the program, so the rewrite is REFUSED naming the directive — never dropped in silence, which is what this paragraph's first sentence requires of it. A conversion under --force replaces its target whole, the program that target held included: it names what the target carried that the new text does not — the comment lines, counted with the first named (a .bot's frontmatter comments included), and a .bot's frontmatter keys, compared by key since the writer spells the frontmatter its own way — or, for a target it cannot read whole (a .bot the parser only recovers on; a document that is not YAML, comments alone or several documents), that it could not count them. A program the target described and the new text does not is not among them — the account names what a form cannot carry: the two texts are what --to bot --check, --to yaml --check and git read, and the program is §3's E056.
  6. One reading of a catalog identity. A frontmatter block is taken off the head of a file as the parser files it (parser.Frontmatter) and decoded once (workflowfile.DecodeFrontmatter) — for the catalogue, the author writer and fmt's notices alike — so a document's catalog: is what the catalogue reads off its .bot.
  7. dsl: is required (E052): a new surface has no profile to guess.

What is not promised ​

  • The lexical class is moved, not removed. YAML has its own traps. The syntax errors an author meets first name the spelling that avoids them — every cause, when the scanner's one message has several — and the documentation states the three rules a YAML author needs that a .bot author does not; other scanner messages still come bare (#1783). One trap is no error at all: a space and # end a plain value and start a comment. fmt --to bot counts the comments it leaves out of the .bot and names the first; validate reads the value that is left (#1780). A block scalar under a property whose value is one word keeps its final line break until the run refuses it (#1781).
  • Comments, the markers, and the stream directives. A conversion carries no comment either way: --to bot and --to yaml each count the ones they leave behind, and a --force the ones it replaces. A rewrite in place keeps them (#1760), or is refused naming the first it would lose (#1784). The explicit document markers used to be on this list and are not any more: since #2359 --- and ... are re-emitted off the source with the lines written around them (§5). What is left of it is one line: a %YAML/%TAG directive opening a stream, which the writer does not emit and which REFUSES the rewrite, named, the author's bytes left as they are — a comment written on a directive's line named with it. The indentation, the blank lines inside the document, the order of the keys and the spelling of a value are not on this list at all: they are the canonical form's own, and §4 says what a rewrite re-spells.
  • The spelling of a round trip. --to bot writes the frontmatter as the writer spells a catalog: (a list as [a, b]), whatever the original .bot wrote.
  • Case. IsAuthorDocument folds the suffix's case, IsWorkflowFile does not (#1762); neither fmt --to bot nor diagram takes a document whose .bot name they would not read back.

Alternatives rejected ​

  • A document discovered or launched. Two truths that can diverge, and a matrix of consumers nobody maintains (098-dsl-versioning-and-authoring-surface.md § 4).
  • Reading the YAML straight into the AST. A second interpretation of every property, beside the parser's; spelling the text keeps one.
  • Edges as YAML mappings. A second grammar of edges; the .bot's edge line is the one a reviewer reads.
  • A document travelling inert in a bundle. It would move the bundle's hash, and break botlock for every dependant, for a file no runner reads.
  • A declaration mirror beside the compiler — comparing the two files' own declarations wherever neither compiles to a workflow alone, which is every .bot carrying an import (33 of 179 tracked here). Measured over six review rounds: each one found the next thing the mirror mis-modelled — the rank the compiler reads for workflows, then the one it reads for supervisors, then an import line read as a program difference where the two merged programs are one. A model of the compiler written beside the compiler does not converge; the unit loader and ir.SameProgram answer for both sides (§3). fmt --to yaml's write proof keeps no hand-built declaration mirror either: its no-workflow fallback compares parser readings of the two serialised ASTs (§4), still on the round-trip from a .bot to the document just written FROM it, not on two files nothing wrote from the other.
  • E056 as a byte comparison — the pair fmt --to bot --check reads. Measured on this tree: 173 of 179 tracked .bot files carry a comment line, a form no document has, so the warning would fire for ever on every author adopting the YAML form of a commented .bot, and its one sentence would have to enumerate four causes, three of them the same program written otherwise. fmt --to bot --check keeps the canonical form; validate speaks of programs (§3).
  • Recreating an empty directory at extraction. Two archives that hash alike would land different trees in one cache slot, whichever was opened first.

Consequences ​

  • The author JSON Schemas (one per profile, and a combined one) and author-schema.md are rendered from the property registry, and task dsl:check holds them fresh; the page's top-level keys are held to the schema's root, its value forms to the forms the registry uses.
  • Every ```yaml author fence of the documentation is read by the converter and compiled in CI, a valid one in its canonical form; every standalone or fragment ```iter fence round-trips through the author document as the corpus does.
  • Follow-ups opened at #1720's merge: #1760 (comments through fmt), #1761 (the latency of the E054 proof), #1762 (the case of the suffix), #1763 (validate's reading of a stale .bot beside a document, and the form of bot_path), #1764 (one parse per bot at discovery). And with this ADR: #1780, #1781, #1783, #1784 above, and #1782 (the engine's wip-bank must prove it is in its own worktree before committing — found while this lot was reviewed).