Skip to content

v0.3.3

Choose a tag to compare

@ghchinoy ghchinoy released this 17 Aug 13:58
894c814

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 188372a was
overridden at merge, which suppressed GitHub's automatic (#N) append, and the commit body carries
no Closes footer. The commit is on main and 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 \r between two keys and an existing verified: list, v0.3.1 and v0.3.2 appended
    the new stamp as a second verified: 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 one verified: holding both entries.
  • The whole file refused. A lone \r immediately before the closing --- made v0.3.2 report
    unparseable frontmatter: invalid frontmatter: unterminated '---' block and 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 enrich was 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 enrich tool — measured on the candidate over a live stdio session,
    the advertised tool set is convert, graph, lint, list_graphs, query_graph, review,
    validate. This is pinned by a test, so it fails loudly if it ever drifts.

  • How a verified stamp 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 a verified_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.yaml exists 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.yaml holding no trust key whatsoever
      (default_type: Note), the global verified_by did 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.yaml that does set verified_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_BY does not authorize stamping; binder ignores it and says so in the run report.
  • On the MCP surface the only route is the verified_by input to the convert tool call — there
    is no default route. That surface loads no config file, and the one environment variable it
    reads is SOURCE_DATE_EPOCH (internal/mcp/server.go:85). That is stated with its scope on purpose:
    "reads no environment variable" would be shorter, and false.

  • lint writes nothing. It runs the same analysis path as convert on 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 (pin SOURCE_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.

  • 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 commit verified has 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. sources likewise: 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.