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)