Round 2 of the PR #147 review forged origin: static for yaml.v3 with an alias key plus a "..." document-end line: per-line judgement cannot see context-dependent YAML. It also found REST ingest writing pages with no provenance (stampPages lived only in the MCP server). - New package internal/provenance. Apply keeps only top-level groups `key: value` with a bare-identifier key whose group decodes on its own to exactly that key, drops reserved and duplicate keys and everything else (aliases, anchors as keys, document markers, complex/merge keys, flow maps, tags, quoted keys), appends the stamp, then decodes the whole result with yaml.v3 and refuses the write unless it reads back exactly the stamp. api.StampProvenance wraps it and now returns an error, which every caller propagates. - The ingest pipeline stamps every page it writes or merges, from provenance.FromContext(ctx); MCP attaches the caller's stamp, REST and the watcher stamp unknown. RunRaw takes a ctx. - brain_edit snapshots the note and rolls the edit back if the stamp cannot be verified. Tests: R1/R2 payloads join the write/update variant table, plus brain_edit with the alias payload; REST /ingest-raw (title newline checked with the line-oriented reader) and REST /ingest (stub LLM) stamp unknown. Two existing byte-exact tests updated for the stamp line. Refs #145 #144 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
7.4 KiB
Go
197 lines
7.4 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
|
|
// Origin/WrittenBy re-stamp provenance to the updating principal: the
|
|
// body is replaced wholesale, so the last writer is the author of what
|
|
// a reader sees (hyperguild#145). Never taken from Content's fragment.
|
|
Origin string
|
|
WrittenBy string
|
|
}
|
|
|
|
// 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)
|
|
|
|
fm, _ := parseFrontmatter(string(prior))
|
|
for _, l := range contentFM.lines {
|
|
// updated_at/supersedes/supersede_reason are this function's own
|
|
// output, not something a caller's fragment sets — updated_at and
|
|
// supersedes are already unconditionally re-set below regardless of
|
|
// what the loop does with them, but supersede_reason is only
|
|
// re-set when opts.Reason is non-empty, so without this skip a
|
|
// caller could set it via its own fragment and have it survive
|
|
// even when Reason is empty, bypassing the one intended entry
|
|
// point for that field (hyperguild#125, found in #111's review).
|
|
// Skipping all three here rather than relying on the unconditional
|
|
// overwrites below to save the other two keeps the invariant
|
|
// explicit at the one place it can be silently broken by a future
|
|
// reordering, not implicit in call order.
|
|
if l.key == "" || l.key == "updated_at" || l.key == "supersedes" || l.key == "supersede_reason" ||
|
|
l.key == "origin" || l.key == "written_by" {
|
|
continue
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
out := []byte(stamped)
|
|
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
|
|
}
|