butler-source-format
id: butler-source-formatkind: measured-tripwiremeasured_on: 2026-08-21stale_when: > the `yaml` package's bundle cost changes materially, Workers gains a native YAML parser, esbuild gains code splitting for Workers so a deferred module's bytes stop shipping in the one script, or #87 decides Butler source is authored somewhere other than this Nodevalues: butler.yaml_parser_bundle_kib: 246 butler.yaml_parser_gzip_kib: 51 butler.yaml_max_alias_count: 100The deferral this closes
Section titled “The deferral this closes”docs/butler-ast.md and src/butlers.ts both said the same thing: “§16’s YAML form arrives when a
YAML parser arrives in the bundle.” That is a condition no one can evaluate. It names no cost, no
threshold and no decider, so it could be repeated in every review forever, which is what it did for
three layers. AGENTS.md calls that shape a landmine: correct today, with nothing to notice.
So the sentence is replaced by a number.
Measured
Section titled “Measured”yaml 2.9.0, imported from the Butler authoring path and actually invoked, so nothing is tree-shaken
away. wrangler deploy --dry-run against the deployed Worker’s own bundle:
| Raw | Gzip | |
|---|---|---|
| Worker without it | 1842.07 KiB | 473.23 KiB |
With yaml invoked, nothing else changed | 2088.24 KiB | 523.98 KiB |
| The parser’s cost | +246.2 KiB | +50.8 KiB |
| Shipped: #87 complete, parser and feature | 2099.16 KiB | 526.44 KiB |
| What a deploying operator actually pays | +257.1 KiB | +53.2 KiB |
Both rows, because they answer different questions and quoting one for the other is how a receipt starts lying. The middle figure is what the decision was made on: a probe that changed one import and nothing else, so it prices the dependency. The last is what a Node’s bundle does after the whole feature lands: the parser plus the format column’s code paths, the YAML starter template, the selector and its styles. The extra 11 KiB is Mailda’s own, and it is mostly the comments that are the reason for the feature.
(Gzip drifts ~0.1 KiB between identical runs, 473.15 and 473.23 for the same tree. The figures are recorded to the tenth and the budget values are integers, because a tenth of a KiB is below the measurement’s own noise and a budget carrying a digit it cannot reproduce is a false precision.)
Against a Workers Paid script limit of 10 MB compressed, this is about 1% of a ceiling the Worker is currently using 5% of. The limit is not what decides this.
What deferring the import buys, stated exactly, because the obvious claim is wrong
Section titled “What deferring the import buys, stated exactly, because the obvious claim is wrong”The import is await import("yaml") inside the parse branch. The tempting sentence, “so it stays
out of the bundle for everything that is not authoring”, is false here, and checking it is why
this section exists.
wrangler deploy --dry-run --outdir emits one index.js. esbuild does no code splitting for the
Workers target, so a dynamic import is inlined and wrapped in its __esm lazy-init idiom. The bytes
ship in the single script either way; postal-mime is in it too, 37 times over.
What the wrapper actually buys is that the module’s top-level initialization does not run until
something awaits the import. So an inbound message, the hot path that runs on every
delivery, pays the parse-and-compile weight of a larger script and not the cost of constructing
yaml’s schema tables, resolvers and stringifier. That is a real saving and a smaller one than “not
in the bundle”, and the difference is the whole reason to write it down.
Decided: adopt it
Section titled “Decided: adopt it”Three reasons, in the order that decided it.
1. JSON cannot hold a comment, and a Butler is a program. This repository’s entire practice is that the argument lives next to the code. AGENTS.md requires it, every file here does it. A Butler definition authored in JSON is the one program in this system that structurally cannot carry the reason for a step next to the step. That is not a formatting preference; it is a class of knowledge the current format destroys, and it is the reason §16 charts a YAML editor rather than a prettier JSON one.
2. The dependency is already here and already trusted. yaml is a devDependency of this Worker
and test/node/ci-policy.test.ts parses the CI workflow with it. Adopting it at runtime adds bundle
weight, not a new supply-chain decision. The alternative, a small hand-rolled subset parser, is
the trap mime-header-parse.md names in its first paragraph: hand-rolling a format is how parser
bugs become security bugs, and it would be worse here than for MIME because the parsed result is a
program rather than a subject line.
3. The cost is bounded and off the initialization path. 51 KiB gzip, ~1% of the ceiling, with
yaml’s own construction deferred past every delivery.
Recorded plainly so it is not read as a loophole: this is a larger figure than the +25.6 KiB
mime-header-parse.md deferred in August, and it is being spent while that one was not. The
difference is not the number. It is that the earlier decision had a cheaper correct alternative for
what that layer needed (headers are tokens, and parsing them badly mis-threads a conversation),
whereas there is no cheap correct way to parse YAML, and no other way at all to let an author write
down why a step exists.
One-way, by decision: YAML in, never YAML out
Section titled “One-way, by decision: YAML in, never YAML out”§16 gives the visual builder and the text editor “the same canonical definition”, and blueprint:1850
says the canonical JSON AST is generated by the editors. Read together with what this Node already
does, that the AST is derived from source_text and never sent beside it, the direction is settled:
source_text → AST is the only conversion. There is no AST → YAML renderer, and there must not
be one.
A renderer would be the feature that destroys the thing YAML was adopted for. Regenerating a
document from an AST cannot reproduce comments, blank lines or key order, because none of them are in
the AST. That is what canonicalization means. So a round trip would silently delete every reason
an author wrote down, and it would do it on the most ordinary act imaginable: open a Butler, change
one field, save. The author’s text is the record; it is stored verbatim, frozen on publication by
btv_frozen, and the only thing derived from it is the program.
The consequence, said rather than discovered later: the visual builder cannot edit a YAML Butler. A graph editor writes an AST, and writing an AST back out requires the renderer that does not exist. Both editors reading one format is a §16 promise this Node keeps in one direction only, and the column below is what makes which direction a fact rather than a guess.
source_format earns its column now, and could not have before
Section titled “source_format earns its column now, and could not have before”docs/butler-ast.md refused this column in exactly the right words: “A column whose only value is
'json' is the placeholder shape placeholder-columns.test.ts exists to catch.” That was correct.
It stops being correct in the same commit that adds a second value, and not one commit earlier.
It is NOT NULL DEFAULT 'json' with a CHECK, so every row written before this migration is
'json', which is not a backfill guess but the truth: JSON is all this Node has ever accepted.
The column is in btv_frozen’s frozen set, for 0031’s reason. A published version whose format could
be flipped is a version whose source_text would be re-parsed by a different parser than the one
that produced its frozen AST, and E_BUTLER_DRAFT_INCOHERENT, the refusal that exists to make
AST/source correspondence a property at the moment of freezing, would be the thing detecting it,
one publish too late.
Bounds
Section titled “Bounds”-
butler.yaml_max_alias_count = 100: the alias expansions one parse may perform.This is the one bound YAML needs that JSON does not, and the byte limit cannot stand in for it.
d1.max_row_bytesprices the stored text and is checked after the parse; alias expansion is amplification, so a few hundred bytes of anchors and aliases can expand to gigabytes before any size check incheckedis reached, against the 128 MB memory limit. The guard has to be inside the parse or it is not a guard.Sized rather than measured, and the size is
yaml2.9’s own default, adopted deliberately rather than inherited. A Butler using more than a hundred alias expansions is a generator’s output rather than something a person wrote to be read, and the format was adopted for the person.Recorded because it is the kind of thing a reader should not have to discover: passing this option is behaviourally invisible today. The library would apply 100 whether or not
parseSourcenames it, so no test can distinguish the two, and this repository has twice deleted a defence in that position. It is kept because it is not a duplicate enforcement. It is the only thing that makes this value true. Delete it andbutler.yaml_max_alias_countbecomes a description of a dependency’s default sitting in Mailda’s own namespace, free to move on the nextpnpm updatewith this receipt still claiming Mailda chose it.test/butlers.test.tspins the literal against the budget, and that test earned its shape the hard way: the first version built its document from the budget and assertedbudget - 1parsed whilebudgetdid not, which is true for every value, so raising the budget to 1000 left it green. It tested the library, not this Node. The counts are written out now.No matching bound is added for JSON.
JSON.parsehas no amplification: the output is bounded by the input, which is what makes the existing byte check sufficient there and insufficient here. -
Not bounded here, named so its absence is a decision: input size before parse. Both formats are parsed before
d1.max_row_bytesis checked, so both are bounded only by the platform’s request body limit. That is pre-existing, identical for JSON, and unchanged by this receipt. The new exposure YAML brings is amplification, and amplification is what the alias count bounds.