Consolidate the three independent frontmatter-fence parsers #138
Closed
opened 2026-09-23 08:39:52 +00:00 by mathias
·
2 comments
No Branch/Tag Specified
main
fix/pipeline-writes-158
fix/edit-path-io-192
fix/create-regular-188
fix/guard-special-179
fix/linkcheck-keeps-write-178
fix/write-dir-redirect-173
fix/tunnel-detect-174
fix/pipeline-root-157
w3/154-mcp-auth-wiring
fix/tunnel-traversal
test/in-brain-targets-171
fix/brain-scan-root-157
fix/graph-wiki-root-157
fix/vectorstore-root-157
fix/notes-root-157
fix/search-root-157
fix/brainwide-root-157
fix/raw-symlinks-153
feat/symlink-tripwire-157
fix/rest-auth-149
fix/123-brain-edit-envelope-check
feat/106-page-aware-extraction
fix/105-source-drift-on-main
fix/104-surface-merge-conflicts-on-main
agent/b1-compile-on-ingest
agent/104-surface-merge-conflicts
agent/105-source-drift
agent/107-loud-unconfigured-alerting
feat/brain-read-edit
fix/capture-i1-refusal-names-target
renovate/configure
feat/consolidate-single-harness
icebox/cmd-routing
feat/brain-pending-promote
fix/close-session-classification-refresh
fix/capture-summarywriter
v0.24.0
v0.23.1
v0.23.0
v0.22.0
v0.21.1
v0.21.0
v0.20.1
v0.20.0
v0.19.1
v0.19.0
v0.18.4
v0.18.3
v0.18.2
v0.18.1
v0.18.0
v0.17.0
v0.16.1
v0.16.0
v0.15.0
v0.14.0
v0.13.2
v0.13.1
v0.13.0
v0.12.1
v0.12.0
v0.11.2
icebox/cmd-routing-2026-07-01
v0.11.1
v0.11.0
v0.10.0
v0.9.0
v0.8.0
v0.7.0
v0.6.0
v0.5.0
v0.4.1
v0.4.0
v0.3.0
v0.2.0
v0.1.0
Labels
Clear labels
agent/ready
blocked/client
blocked/supplier
blocked/upstream
risk/high
risk/low
risk/medium
size/l
size/m
size/s
size/xs
status:dispatch
status:dispatched
status:review
status:reviewing
type/chore
type/decision
type/docs
type/feat
type/fix
type/ops
type/research
type/test
Meets the agent-ready contract; authorizes unattended execution
Waiting on the client
Waiting on a supplier
Waiting on an upstream project or vendor
Irreversible, security-relevant, or control plane
Reversible, local, test-covered
Touches shared state, deploys or data
Too big - split it
Under two days
Under half a day
Under an hour
Maintenance, no behaviour change
Human decision required; never agent/ready
Documentation only
New capability
Corrects broken behaviour
Infrastructure, deploy, runtime
Investigate; output is a finding, not code
Test coverage only
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mathias/hyperguild#138
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Context
ingestion/internal/mcp/tools_notes.go#123's claim-envelope check forbrain_edit(issue #123, PR #136) took 4 review rounds to close, each finding a structurally distinct bypass:after— bypassed by an edit that strips the closing fence, degrading to "unfenced" (empty map validates trivially).hadFence && !hasFence) — bypassed by CRLF-fenced notes, wherestrings.HasPrefix(doc, "---\n")false-negatives on the precondition read purely from line-ending encoding, no adversarial crafting needed.Round 4's reviewer's explicit judgment: "The fence-based, byte/string-matching strategy has now failed four structurally distinct ways across four rounds... continuing to patch
checkClaimEnvelopein isolation is likely to keep producing new shapes of the same problem."The actual root cause
Three independent reimplementations of "find the frontmatter fence" exist in this codebase, each with subtly different matching rules:
internal/mcp/tools_notes.go'sfrontmatterBlock—strings.HasPrefix(doc, "---\n")+strings.Index(rest, "\n---\n"), now CRLF-normalized (round 5's fix, this PR).internal/api/frontmatter.go'sparseFrontmatter/split.internal/frontmatter/frontmatter.go'ssplit— uses a looser"\n"+fencesubstring match with no trailing-newline requirement.Nothing keeps them in lockstep. The CRLF bug in round 5 is a direct symptom: fixing it in one implementation doesn't fix it in the other two, and any future encoding/whitespace edge case has to be independently rediscovered and independently patched in up to three places (this has literally already happened once this session, for the indented-list-item / indentation-guard fix across
internal/api/frontmatter.go,internal/wiki/merge.go, and this file'sfrontmatterScalars— hyperguild#126/#128/#123).Recommendation
Consolidate into one shared, tested fence/frontmatter parser (normalizing line endings once at the boundary), imported by all three call sites, so this class of bug is closed by construction instead of by the next reviewer finding the next byte sequence that breaks a hand-rolled
HasPrefix/Indexpair.Deliberately not done as part of #123/PR #136: that PR's scope is "brain_edit validates the claim envelope," and this is a larger cross-package refactor (
internal/api,internal/wiki,internal/mcpcurrently deliberately don't import each other for this). Filing separately per the surgical-changes principle — fix the reported bug narrowly, track the structural improvement on its own ticket.Source
Found during round 4 of
pr-gatekeeperreview on PR #136 (branchfix/123-brain-edit-envelope-check). Full round-4 finding: issue #123 comment.Created via git-mcp on behalf of @mathias
PR #139 review — not merged. Found a real, code-confirmed regression in
internal/frontmatter.Split's tightened closing-fence match, in a category the review brief explicitly called out as disqualifying ("a behavior change in Lint/Fix").What I independently verified
Byte-offset claim (block/rest TrimSuffix/TrimPrefix in
internal/apiandinternal/mcp) — traced by hand and confirmed with a standalone Go repro against the exactSplitbody:blockalways ends with exactly one\n(the separator's leading newline, regardless of how many blank lines precede the fence — verified with a doc containing a blank line directly before the closing fence), andrestalways begins with exactly one\n(the fence's mandatory trailing newline).TrimSuffix/TrimPrefixremove exactly that one byte in everyok=truecase and reproduce the oldinternal/api/internal/mcpcontracts byte-for-byte. This part of the claim holds.Flip test on
internal/api/frontmatter.go: removed theTrimSuffix/TrimPrefixcompensation, ranTestParseFrontmatterSplitsHeaderAndBody,TestFrontmatterRenderRoundTrips,TestFrontmatterRenderEmptyValueKeyStaysBareNoTrailingSpace— all three went red for the exact claimed reason (spurious blank line before/after the fence, e.g."---\nwing: a\nhall: facts\n\n---\n"vs expected"---\nwing: a\nhall: facts\n---\n"). Restored the compensation, all three green again. Claim holds.All six of #123's regression tests (CRLF fence, BOM prefix, old_str crossing the closing fence, edit removing the closing fence, folded scalar hiding an invalid value, plus the envelope tests) pass under the consolidated parser.
go build ./... && go vet ./... && go test ./... -race -count=1— all 31 packages green.The regression
Split's closing-fence match was tightened fromstrings.Index(body, "\n"+fence)tostrings.Index(body, "\n"+fence+"\n")(require a newline after the closing fence too, not just before it). Forinternal/apiandinternal/mcp, this is a no-op — both of their pre-existing hand-rolled parsers already required a trailing newline after the close, so no behavior changes there.But
internal/frontmatter's own privatesplit(nowSplit) did not require that trailing newline before this PR. Consolidating onto the tightened match silently changesinternal/frontmatter.Lint/Fix's own behavior — the thing the PR's own comments claim is "zero behavior change" ("Split replaces private split with identical external behavior for Lint/Fix's own call sites, just tightened for the closing-fence edge case").Confirmed against the real package (not a reimplementation) with:
Impact:
internal/frontmatter.Scan(wired tocmd/frontmatterlint, the repo-hygiene linter overbrain/) will silently stop flagging a bodyless note whose frontmatter closing fence is the literal last bytes of the file (no trailing newline) as broken, even if its YAML doesn't parse — a false negative in the linter, not a crash. This does not touch the security-critical path:internal/mcp'scheckClaimEnvelopeandinternal/api'sparseFrontmatterboth already required the trailing newline in their pre-PR implementations, so #123's claim-envelope validation is unaffected by this specific edge case.Also noted, out of blocking scope but worth a follow-up issue:
internal/wiki/merge.go'sparseFrontmatterBlock(line 184) is a fourth hand-rolledHasPrefix/Index-based fence parser, not touched by this consolidation. It isn't named as one of the "three independent reimplementations" in #138's own root-cause section, so it's not a #138 acceptance-criteria violation — but it's the same class of drift #138 exists to close, and root_causing this fully would eventually want it folded intofrontmatter.Splittoo.Verdict: BLOCKED, not merged. The fix is small (add a no-newline-at-EOF test to
frontmatter_test.gocovering the bodyless/no-trailing-newline case, and either special-case it inSplitor accept the narrowing explicitly with a comment + aScan-level regression test) — recommend the author add the missing coverage and a one-line acknowledgment of the narrowed edge case before re-requesting review, or explicitly accept the narrowing as intentional with a comment explaining why it's safe forScan's purposes.CI: green (run 7946, job "Lint / Test / Vet", head sha
a75fd95a, matches PR #139's exact head commit).PR #139 merged (squash commit
3d5af85713). Round-2 verification, independent of the author's claims.Round 1 recap (previously posted here): found a real regression in
Split's tightened closing-fence match — a document whose closing fence is the literal last bytes of the file ("---\ntitle: x\n---", no body at all) went fromok=truetook=false, silently narrowinginternal/frontmatter.Lint/Fixand contradicting the PR's "zero behavior change" claim. Blocked, not merged.Round 2 — commit
625955efix verified:Read the actual diff.
Splitnow branches on three cases after the closing fence:after == len(body)(fence is EOF, valid),body[after] == '\n'(real body follows, valid), default (anything else, e.g."\n---extra", invalid). Confirmed by reading the code directly — theafter == len(body)bounds check is evaluated in the switch beforebody[after]is ever indexed, so there is no out-of-bounds read possible via case ordering.Flip-tested independently (not just trusting the new test):
after == len(body)case toreturn nil, nil, false→TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocumentwent red,TestSplit_RequiresANewlineAfterTheClosingFencestayed green. Restored, both green again.defaultcase to accept instead of reject →TestSplit_RequiresANewlineAfterTheClosingFencewent red (the original round-1 bypass reopened),TestSplit_AcceptsAClosingFenceThatIsTheLastBytesOfTheDocumentstayed green. Restored, both green again.Reproduced round 1's exact repro by hand, independently, against the real package (not the new test):
doc := []byte("---\ntitle: x\n---")→Split(doc)returnsok=true,block="title: x\n",rest="". The specific regression is gone.Adversarial pass for a fourth shape: probed
"---\n"(opening fence only, empty body),"---\n---"and"---\n---\n"(fence-adjacent with no gap), bare"---"with no newline, empty doc"", and"---\n---extra"— all correctly returnok=false, no panic, no out-of-bounds access. Also checked the minimal legitimate empty-block case"---\n\n---"→ok=true, block="\n". No fourth gap found.Full gate re-run:
go build ./...,go vet ./...both clean;go test ./... -race -count=1— checked actual exit code (0) andgrep -c FAIL(0) on the full log, not just tail output. All 31 packages (?on two with no test files) pass.CI: commit
625955e, run 7954, "CI / Lint / Test / Vet (pull_request)" = success. PR was mergeable, no conflicts.Filed as follow-up, not blocking: #141 —
internal/wiki/merge.go:184'sparseFrontmatterBlockis a fourth hand-rolled fence parser, same drift class as #138, not named in #138's own scope and therefore not touched here. Tracked separately per round 1's note.Verdict: MERGED.