fix(api): validate the claim envelope on supersede, same as on create
hyperguild#111: writeHallNote validates half_life/applies/claim_provenance before a note is born; UpdateNote's supersede path didn't, so the fields that need provenance most (a whole-note regenerate is more likely to carry model-authored frontmatter than a fresh create) went unchecked. Fixed by extracting any frontmatter fence the caller leads Content with, validating it with the same claim.ValidateEnvelope, and merging its fields into the restamped frontmatter instead of leaving it as a second, inert `---`-fenced block in the body — that merge was a real gap independent of validation: a caller's supersede-time frontmatter was previously silently dropped, never taking effect at all. Reject leaves the note byte-identical (fail-closed, nothing written). Fence-less content (the common case today) is unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
)
|
||||
|
||||
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
||||
@@ -98,14 +99,39 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
}
|
||||
priorHash = ContentHash(prior)
|
||||
|
||||
// A caller may lead opts.Content with its own frontmatter fence to set or
|
||||
// change fields on supersede (e.g. the claim envelope). Split it off
|
||||
// before it reaches the body — left un-split, it would render as a
|
||||
// second, inert `---`-fenced block of literal text rather than taking
|
||||
// effect, and any claim-envelope fields inside it would reach disk
|
||||
// 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 {
|
||||
if l.key != "" {
|
||||
fm.set(l.key, l.value)
|
||||
}
|
||||
}
|
||||
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
||||
fm.set("supersedes", priorHash)
|
||||
if opts.Reason != "" {
|
||||
fm.set("supersede_reason", opts.Reason)
|
||||
}
|
||||
|
||||
out := []byte(fm.render() + opts.Content)
|
||||
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