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>
166 lines
5.6 KiB
Go
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
|
|
}
|