v0.3.3
0.3.3 (2026-08-17)
Bug Fixes
- correct block-scalar span undercount in frontmatter splice (188372a)
- preserve all keys when frontmatter contains a lone CR (#129) (c6d1241), closes #123
binder v0.3.3
v0.3.3 is a correctness release. It closes the two defects that made v0.3.1 and v0.3.2 unsafe for any
corpus containing multi-line attestation notes or a stray carriage return in frontmatter, and it
removes an absolute claim from binder's own help text.
binder's goal is to operate only on the YAML. That is what it aims at, and it is the honest form
of the sentence this release deletes — a goal stays true where a bug defeats it, and a guarantee does
not. These notes state what binder aims to do, what it now does, what it does not yet do, and where
that is tracked. They avoid promising results, including about the fixes below.
Two changes since v0.3.2:
- fix: correct block-scalar span undercount in frontmatter splice (
188372a) — the trust
fabrication and attestation-content loss tracked as #132 - fix: preserve all keys when frontmatter contains a lone CR (
c6d1241, PR #129) — closes #123
⚠️ Most serious: binder could destroy attestation text and assert a trust claim no one made — fixed (issue #132)
This is the most serious defect in the v0.3.x line, and it is tracked as issue
#132. On v0.3.1 and v0.3.2, adding an attestation
with enrich --verified-by to a file that already held an attestation whose note (or any field) was
written as a multi-line block scalar would:
- destroy the existing notes' text irrecoverably — with more than one block-scalar attestation,
every one but the last loses its content; and - fold the orphaned lines into the new stamp's actor identity, producing an actor no human ever
wrote.
Measured on three binaries — v0.3.1 (8090ea8), v0.3.2 (bceba9f) and the v0.3.3 candidate
(188372a) — each given its own copy of the same LF-only file, with no unusual characters, and each
run with the same command (enrich --verified-by human:probe). A fourth copy was kept untouched. All
four were then read by a short program that parses with the stock YAML library rather than binder's
codec and prints the byte count, the top-level keys and each attestation entry. Its output, verbatim:
=== pristine/doc.md ===
bytes=233 CR=0 LF=16 preprocessing=false
keys=[title type verified]
entry[0] by="human:person1" note="note1_a\nnote1_b\n"
entry[1] by="human:person2" note="note2_a\nnote2_b\n"
=== v031/doc.md ===
bytes=316 CR=0 LF=20 preprocessing=false
keys=[title type verified generated]
entry[0] by="human:person1" note=""
entry[1] by="human:person2" note=""
entry[2] by="human:probe note2_a note2_b" note=<absent>
=== v032/doc.md ===
bytes=315 CR=0 LF=19 preprocessing=false
keys=[title type verified generated]
entry[0] by="human:person1" note=""
entry[1] by="human:person2" note=""
entry[2] by="human:probe note2_a note2_b" note=<absent>
=== cut/doc.md ===
bytes=373 CR=0 LF=21 preprocessing=false
keys=[generated title type verified]
entry[0] by="human:person1" note="note1_a\nnote1_b\n"
entry[1] by="human:person2" note="note2_a\nnote2_b\n"
entry[2] by="human:probe" note=<absent>
On the candidate both notes come back matching the untouched copy, and the new stamp carries only the
actor that was passed. It needed no carriage return, no unusual character and no special flag to go
wrong, and neither binder validate nor binder review reported anything: that is why it is listed
first.
One thing about that evidence block, because it is the most persuasive thing in these notes. It
reads as though a second YAML parser detected the damage. It did not, on its own. It could only show
you note="" — and an empty note is what a destroyed note and an always-empty note both look
like. What makes the block discriminating is the pristine row — the untouched copy the harness
kept, which is the only line that establishes what the notes said before. If you are checking your own
corpus, the re-parse is not the instrument that will tell you; the diff against an untouched copy is.
If you stamped a corpus with v0.3.1 or v0.3.2, upgrading does not restore what was lost. The
deleted note text is gone from the file and has no second copy. Recover it from version control or
from a copy binder never touched; a damaged file re-parses cleanly and reads back note: "", and
an empty note reads identically whether it was destroyed or was always empty, so the file itself
cannot tell you which happened. Only a diff against an untouched copy can.
Why the changelog does not link this fix to #132. The squash subject for
188372awas
overridden at merge, which suppressed GitHub's automatic(#N)append, and the commit body carries
noClosesfooter. The commit is onmainand is immutable, so the generated changelog entry can
only ever carry a commit link — it can never link this fix to the issue describing it. That is
why the issue number is written out here in prose: for the most serious defect in this release line,
these notes are the only route from the fix back to #132.
Rewriting a commit already on a shared base for a cosmetic suffix was judged the worse trade, so
this is disclosed rather than repaired.
Fixed: a stray carriage return in frontmatter no longer corrupts or refuses your file (#123)
A single lone \r inside frontmatter — the kind an editor or a copy-paste leaves behind — had two
distinct bad outcomes on v0.3.1 and v0.3.2. Both are fixed:
- A duplicated key, and a file no YAML parser will read. On a file whose frontmatter contained
both a lone\rbetween two keys and an existingverified:list, v0.3.1 and v0.3.2 appended
the new stamp as a secondverified:key rather than merging into the existing one. The result
parses in binder and is rejected outright by a standard YAML reader:
yaml: unmarshal errors: line 4: mapping key "verified" already defined at line 3. On the candidate
the same file comes back with oneverified:holding both entries. - The whole file refused. A lone
\rimmediately before the closing---made v0.3.2 report
unparseable frontmatter: invalid frontmatter: unterminated '---' blockand skip the file
(1 skipped; exit 1 under--strict). Measured on a v0.3.1 binary too: same message, same skip.
On the candidate the same file is read, enriched and written correctly.
And the bound on that, measured, because the scarier version of this section would be wrong. A
lone \r between two ordinary keys, in a file with no verified: list, was not damaged by v0.3.1
or v0.3.2: the CR line was passed through untouched and the new keys appended after it. status did
not disappear. The two outcomes above needed the CR plus something else — a verified: list to
append into, or the closing fence immediately after. If you are auditing a corpus, that narrows what
you have to look at.
Files that earlier releases refused as malformed are back in scope
This is a behaviour change, and it is the one to read twice if you keep a set-aside pile. Shapes
that v0.3.1 and v0.3.2 refused — a lone CR before the closing fence, with or without a block-scalar
field inside a verified: entry — are now read and written. What that write did in the cases
measured, each stated with its bound rather than as one combined claim:
- Values. Every pre-existing attestation value came back intact when read by a YAML parser that is
not binder's. No block-scalar note shrank or vanished. - Additions. The added keys were the ones
enrichwas asked to add, and nothing else was added. - On an LF-only file, the diff against an untouched copy is additions only — no line removed,
none altered. - On the file carrying the stray CR, the diff is additions plus one change, and that change is
inside a pre-existing entry: the CR is re-emitted as a newline. It is a re-serialisation of the
same value rather than the loss of one — the parser reads back the identical string either way — but
it is not byte-identical, and printing it as byte-identical would be the over-claim this release
exists to remove. The bound is stated below.
These are the cases that were run. They are not a guarantee about cases that were not.
The practical consequence is a change in what a silent run means to you:
- On v0.3.2, these files produced a loud skip and were left untouched on disk. The refusal was
the boundary of the risk. It still cost you the file — you had to set it aside — but you could
see it happen, and it meant nothing had been written. - On v0.3.3 the same files are written. There is no skip line to read any more, so "binder said
nothing, therefore nothing was touched" is no longer true of this shape. What protects you now is
that the write is correct on the shapes listed above — which is what the fixes are for, and it is a
narrower kind of protection than a refusal you could see.
If you set files aside on v0.3.1 or v0.3.2 because binder refused them, you can bring them back —
and, per the note above, if you stamped anything in that period, diff it against a copy binder never
touched before you trust it.
One bound worth knowing, measured: a stray CR inside frontmatter is written back as a newline when
binder rewrites that file. Body bytes are untouched (a \r in the body survives), and a file that
needs no new key is not rewritten at all, so its CR is preserved. The normalisation happens only to
frontmatter, and only on a run that was already going to write.
binder convert --help states the goal instead of a guarantee
Up to and including v0.3.2, binder convert --help carried an absolute claim about what convert
would never do to your source tree. It is removed in v0.3.3 — from the CLI help, README.md,
docs/tutorial.md, docs/user_guide.md, and the convert.Convert doc comment.
The sentence itself is deliberately not reproduced here. Reprinting it in the document that
announces its retirement would put it back into circulation in the same breath as retiring it. What it
asserted is described above — an absolute about what convert would never do to your source tree —
which is enough to recognise it in an older help text or in docs you have copied elsewhere.
It was removed because an absolute is not an honest thing to print, not because the behaviour
changed. The goal is to operate only on the YAML. That is design intent, it survives the discovery
of a bug, and it is what the help text should have said in the first place.
The behaviour that made the absolute false is unchanged and is still yours to avoid: point -o at
your source directory and convert writes into it — measured on the candidate, which added a
generated: block to a source file and wrote a new index.md there, exit 0. A guard that refuses
when the output path resolves to the source is a follow-up and is not in this release. Keep -o
outside the corpus.
What has not changed
-
The MCP surface exposes no
enrichtool — measured on the candidate over a live stdio session,
the advertised tool set isconvert,graph,lint,list_graphs,query_graph,review,
validate. This is pinned by a test, so it fails loudly if it ever drifts. -
How a
verifiedstamp gets written, re-measured on the candidate by two independent static
methods and by behavioural probe. A stamp is written when you pass--verified-by. Without the
flag, the one remaining route is averified_by:in your global config file — and that route
carries a bound that is worth more to you than the rule it qualifies:⚠️ If a./.binder.yamlexists in the working directory, your global config is never read at
all. Config resolution takes the first file that exists, and repo-local is checked first
(internal/config/config.go:122-135). Measured on the candidate, same environment, the only
difference being that file: with a repo-local.binder.yamlholding no trust key whatsoever
(default_type: Note), the globalverified_bydid not apply, nothing was stamped, and no
trust section was printed at all. The file does not have to mention trust to have this effect —
it only has to exist.- A repo-local
.binder.yamlthat does setverified_by:is refused loudly:ignored repo-local .binder.yaml verified_by "…": a repo-local config does not authorize stamping (pass --verified-by to stamp). That is the case people expect. The silent case above is the one to know. BINDER_VERIFIED_BYdoes not authorize stamping; binder ignores it and says so in the run report.
-
On the MCP surface the only route is the
verified_byinput to theconverttool call — there
is no default route. That surface loads no config file, and the one environment variable it
reads isSOURCE_DATE_EPOCH(internal/mcp/server.go:85). That is stated with its scope on purpose:
"reads no environment variable" would be shorter, and false. -
lintwrites nothing. It runs the same analysis path asconverton both the CLI and MCP
surfaces, but it passes no actor and writes nothing to disk; files are byte-identical after a lint
on either surface. -
The invariants are unchanged: read-only unless you ask for a write; additive rather than
clobbering (the one exception is the opt-in--overwrite-keys, which refreshes only the keys you
name and refuses trust keys); the native codec as the source of truth; deterministic output for
identical input (pinSOURCE_DATE_EPOCH); never-reject (skips are advisory; only--strict
gates). -
Preservation is two claims, not one, and these notes will not merge them.
docs/user_guide.md
splits it deliberately, and says why: "They are stated separately rather than joined by "and",
because a reader who reads them as one claim will over-trust the second."- (a) A top-level key binder does not touch is re-emitted from its original source bytes,
line-for-line. That one holds by construction. - (b) A pre-existing entry inside a container that did change is preserved by a scanner —
pattern recognition over YAML, with limits that get found one case at a time. The CR
normalisation described above is precisely a case where (b)'s bytes moved.
The goal is to operate only on the YAML you asked binder to change. Where you need more than a goal,
the instrument is a diff against a copy binder never touched. - (a) A top-level key binder does not touch is re-emitted from its original source bytes,
-
Never-fabricate-trust is design intent, expressed as a mechanism, not as a guarantee we can prove
universally. Closing #132 shuts the one fabrication path that was found; it does not establish that
no path exists. What can be stated is the mechanism: at this commitverifiedhas exactly one
writer, so the three routes above are its complete set by construction rather than by diligence —
and every one of them requires an actor you named.sourceslikewise: only from keys you named
with--source-keys/--map-citations, or their MCP equivalents. That closure reopens the moment a
second writer appears. The duty stays with you: if an attestation matters, check it against a copy
binder never touched.