fix(api): validate the merged envelope on supersede, not the caller's fragment
CI / Lint / Test / Vet (pull_request) Successful in 32s
CI / Mirror to GitHub (pull_request) Skipped

Review caught a real bypass in the #111 fix before merge: the previous
commit validated only the frontmatter fence the caller supplied, not the
merged result that actually reaches disk. A caller could blank
claim_provenance alone (absent per claim.present's "empty is unset" rule)
while half_life/applies survived untouched from the prior note — each side
individually looked fine, the merged envelope on disk was the
unprovenanced one the whole check exists to catch. That's the exact
laundering #111 named as the worse of its two gaps.

Fixed by computing the envelope from fm (post-merge, pre-write) instead of
contentFM (the caller's raw fragment). Flip-tested: reverting to
fragment-only validation turns the new adversarial test red; the merged
validation turns it green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:24:50 +02:00
co-authored by Claude Sonnet 5
parent 31ebc8afc4
commit e2cb315b31
2 changed files with 42 additions and 11 deletions
+19 -11
View File
@@ -107,17 +107,6 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
// unvalidated (hyperguild#111: the same rule that guards note birth in
// writeHallNote must guard a change to the same fields).
contentFM, contentBody := parseFrontmatter(opts.Content)
if len(contentFM.lines) > 0 {
envelope := make(map[string]string, len(contentFM.lines))
for _, l := range contentFM.lines {
if l.key != "" {
envelope[l.key] = l.value
}
}
if err := claim.ValidateEnvelope(envelope); err != nil {
return "", "", "", fmt.Errorf("claim envelope: %w", err)
}
}
fm, _ := parseFrontmatter(string(prior))
for _, l := range contentFM.lines {
@@ -131,6 +120,25 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
fm.set("supersede_reason", opts.Reason)
}
// Validate the envelope AFTER the merge, against the full field set that
// is about to reach disk — not just the caller's fragment. Validating the
// fragment alone would miss a caller that supersedes claim_provenance to
// empty while half_life/applies survive untouched from the prior note:
// each side looks individually fine, but the merged result is an
// unprovenanced envelope, which is the exact laundering this check
// exists to catch. (Caught in review before merge, not filed after —
// worth being honest about in the history: this second pass was the
// actual fix; the first one validated the wrong document.)
envelope := make(map[string]string, len(fm.lines))
for _, l := range fm.lines {
if l.key != "" {
envelope[l.key] = l.value
}
}
if err := claim.ValidateEnvelope(envelope); err != nil {
return "", "", "", fmt.Errorf("claim envelope: %w", err)
}
out := []byte(fm.render() + contentBody)
if err := os.WriteFile(abs, out, 0o644); err != nil {
return "", "", "", fmt.Errorf("write: %w", err)
+23
View File
@@ -112,6 +112,29 @@ func TestUpdateNoteAcceptsValidEnvelopeAndMergesIntoFrontmatter(t *testing.T) {
"exactly one open+close fence pair — the caller's own fence must not leak in as a second, inert block")
}
func TestUpdateNoteRejectsClearingProvenanceWhileEnvelopeSurvives(t *testing.T) {
// Found in review, not written speculatively: validating only the
// caller's fragment (rather than the merged result) let a caller blank
// out claim_provenance alone while half_life/applies survived from the
// prior note untouched — each side looks fine on its own, the merged
// envelope on disk is the unprovenanced one the whole check exists to
// catch.
brainDir := t.TempDir()
original := "---\nwing: jepa-fx\nhall: facts\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\nold\n"
rel := seedNote(t, brainDir, "wiki/jepa-fx/facts/val-vol.md", original)
_, _, _, err := UpdateNote(brainDir, UpdateNoteOptions{
Path: rel,
Content: "---\nclaim_provenance: \n---\nnew body\n",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "claim envelope")
got, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(rel)))
require.NoError(t, err)
assert.Equal(t, original, string(got), "rejected update must leave the note byte-identical")
}
func TestUpdateNoteWithoutEnvelopeFieldsIsUnchangedFromToday(t *testing.T) {
// Regression pin: plain fence-less content (the common case) must behave
// exactly as it did before the envelope-merge fix.