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
- A draft, never a truth.
x.bot.yamlis a way of writingx.bot; only the.botis 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 floorrunview.compileUnit, a subbot'ssource:(C305), the HTTP source resolver and the launch handler (error_code: author_document), the bot-source store'sValidate, MCPlocal_runandlocal_resume, the remote upload, the schedule. One typed sentinel,bundle.ErrAuthorDocument, names the command that writes the.bot. - 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. - Read, as the
.botit stands for, by the surfaces that write or inspect a.bot.validatereads it in the unit and the bundle of that.bot, at the document's own positions,--execincluded;fmtgives it its canonical YAML form and converts both ways (--to bot|yaml), each conversion proven the same program;diagramdraws that.bot; the MCPlocal_validatereads it.validateanddiagramshare one construction of a document's unit and one E054, anddiagramruns, once the unit is loaded, the very stages the.bot's own diagram runs (runview.CompileLoadedUnit): what refuses the.botrefuses the document.validatealso reports on the.botbeside 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 itsimportlines 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 theirimportlines name, the bytes the document's own unit read, so aprompts/*.mdor alib/*.botsaved 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 toir.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 animportline 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 — asupervisordeclaration lands inSupervisors, auseof a group holding an internal edge inEdges— 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 isfmt --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 underlib/, a file of prompts or schemas alone, a document carrying noworkflow:— describes no program, and their texts remainfmt --to bot --check's; a.botrefused 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.botto, the verdict naming that refusal at its position while the.botis the sound side. Which of the two is current the author alone knows, so it leavesvalidas it is and itsfix:line names the two--forceconversions of §5, each replacing its destination whole. Both surfaces name that.botas the document was named (bot_pathbesidefile, the refusal of a.botpathfmtwill not write over included), the absolute path kept internal; a bundle a document's.botbelongs to is named by its directory's base name, as it is for the.botitself. A manifest export naming the.bota document stands for is that.bot, written or not. - Spelled into
.bottext, read by the one parser.author.Parsespells the YAML into.bottext 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.Writerenders 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.botreads 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, andfmtproves it: a document is written only once it reads back as the program.ir.SameProgramremains the oracle wherever a workflow compiles. Where neither side has one,fmt --to yamlserialises each AST as.bot, reads both with the one parser, and compares the span-free readings: the effective profile is kept,Literal.Rawis 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. - A rewrite never loses what the author wrote in silence.
fmtrewrites 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.botreads 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/%TAGdirective 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--forcereplaces 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.botthe 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 --checkand git read, and the program is §3's E056. - 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 andfmt's notices alike — so a document'scatalog:is what the catalogue reads off its.bot. 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
.botauthor 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 botcounts the comments it leaves out of the.botand names the first;validatereads 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 botand--to yamleach count the ones they leave behind, and a--forcethe 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/%TAGdirective 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 botwrites the frontmatter as the writer spells acatalog:(a list as[a, b]), whatever the original.botwrote. - Case.
IsAuthorDocumentfolds the suffix's case,IsWorkflowFiledoes not (#1762); neitherfmt --to botnordiagramtakes a document whose.botname 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
botlockfor 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
.botcarrying animport(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 forworkflows, then the one it reads forsupervisors, then animportline 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 andir.SameProgramanswer 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.botto 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 --checkreads. Measured on this tree: 173 of 179 tracked.botfiles 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 --checkkeeps the canonical form;validatespeaks 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:checkholds 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 authorfence of the documentation is read by the converter and compiled in CI, a valid one in its canonical form; every standalone orfragment```iterfence 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.botbeside a document, and the form ofbot_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).
