fix(api): validate the merged envelope on supersede, not the caller's fragment
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user