Files
hyperguild/ingestion/internal/api/update.go
T
mathiasandClaude Sonnet 5 c50c64f93b 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>
2026-09-22 23:20:12 +02:00

166 lines
5.6 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"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
// content_hash handle: brain_write / brain_update return it, brain_get
// recomputes it from the file on disk, and brain_update stamps the prior
// note's hash into the new note's `supersedes` frontmatter.
func ContentHash(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// resolveWithin maps a brainDir-relative path to an absolute path and
// guarantees it does not escape brainDir. Returns the cleaned relPath
// (forward-slashed) and the absolute path.
func resolveWithin(brainDir, relPath string) (rel, abs string, err error) {
clean := filepath.Clean("/" + filepath.ToSlash(relPath))
rel = strings.TrimPrefix(clean, "/")
abs = filepath.Join(brainDir, filepath.FromSlash(rel))
check, err := filepath.Rel(brainDir, abs)
if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) {
return "", "", fmt.Errorf("path %q escapes brain dir", relPath)
}
return rel, abs, nil
}
// UpdateNoteOptions identifies the note to supersede and supplies its new
// body. Path takes precedence; otherwise the target is resolved from
// Wing/Hall/Slug via brain.NotePath.
type UpdateNoteOptions struct {
Path string // brainDir-relative path; takes precedence over wing/hall/slug
Wing string
Hall string
Slug string
Content string // new full body (whole-note replace)
Reason string // optional; stamped as supersede_reason
}
// UpdateNote supersedes an existing note in place. It replaces the body
// with opts.Content, preserves the existing frontmatter (created_at,
// wing, hall, and any custom fields), and stamps updated_at, supersedes
// (the prior content hash), and supersede_reason (when given).
//
// It never creates: if the target does not exist, it returns an error so
// the caller can fall back to brain_write. Returns the note's relPath,
// the new content hash, and the prior content hash.
//
// Embeddings are NOT refreshed here. The rewritten file's mtime advances,
// which the mtime-driven vectorstore.Sync ticker uses to re-embed it on
// its next pass — the same out-of-band mechanism brain_write relies on.
func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, priorHash string, err error) {
if opts.Content == "" {
return "", "", "", fmt.Errorf("content is required")
}
// Same reason as WriteNote: the strip belongs in the primitive so every
// door inherits it.
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
if err != nil {
return "", "", "", err
}
opts.Content = clean
var rel string
if opts.Path != "" {
rel = opts.Path
} else {
full, perr := brain.NotePath(brainDir, opts.Wing, opts.Hall, opts.Slug)
if perr != nil {
return "", "", "", perr
}
rel, _ = filepath.Rel(brainDir, full)
rel = filepath.ToSlash(rel)
}
rel, abs, err := resolveWithin(brainDir, rel)
if err != nil {
return "", "", "", err
}
prior, err := os.ReadFile(abs)
if err != nil {
if os.IsNotExist(err) {
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
}
return "", "", "", fmt.Errorf("read target: %w", err)
}
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() + contentBody)
if err := os.WriteFile(abs, out, 0o644); err != nil {
return "", "", "", fmt.Errorf("write: %w", err)
}
return rel, ContentHash(out), priorHash, nil
}
// ReadNote reads the note at the brainDir-relative relPath and returns
// its parsed frontmatter, body, and content hash. It is the read-after-
// write primitive behind brain_get: the hash it returns equals the hash
// brain_write / brain_update returned for the same bytes.
func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) {
_, abs, err := resolveWithin(brainDir, relPath)
if err != nil {
return nil, "", "", err
}
raw, err := os.ReadFile(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, "", "", fmt.Errorf("note %q does not exist", relPath)
}
return nil, "", "", fmt.Errorf("read note: %w", err)
}
parsed, body := parseFrontmatter(string(raw))
fm = make(map[string]string, len(parsed.lines))
for _, l := range parsed.lines {
if l.key != "" {
fm[l.key] = l.value
}
}
return fm, body, ContentHash(raw), nil
}