Files
hyperguild/ingestion/internal/api/promote.go
T
mathiasandClaude Opus 5.5 a6da93332d
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s
feat(brain): audit-log every tools/call; server-owned write provenance (#147)
Closes #144 (audit line per tools/call) and #145 (origin/written_by stamped on every model-reachable write, surfaced in brain_query/brain_get).

Passed pr-gatekeeper after four rounds of adversarial review, verified by execution: #147

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-01 21:06:36 +00:00

227 lines
7.7 KiB
Go

package api
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
)
// PendingNote describes a raw/ note awaiting human promotion to the wiki.
type PendingNote struct {
Filename string `json:"filename"`
CreatedAt string `json:"created_at"`
SizeBytes int64 `json:"size_bytes"`
Excerpt string `json:"excerpt"`
}
// datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when
// deriving the default promoted slug.
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
// ListPending returns the notes in brain/raw/ awaiting review, oldest-first
// (natural review order). An absent raw/ dir yields an empty slice, not an
// error. Only .md files are listed; tunnel-candidate files are skipped.
func ListPending(brainDir string) ([]PendingNote, error) {
dir := filepath.Join(brainDir, "raw")
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return []PendingNote{}, nil
}
return nil, fmt.Errorf("read raw dir: %w", err)
}
out := make([]PendingNote, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
continue
}
info, statErr := e.Info()
if statErr != nil {
continue
}
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
if readErr != nil {
continue
}
fm, body := parseFrontmatter(string(raw))
created := fm.get("created_at")
if created == "" {
created = info.ModTime().UTC().Format(time.RFC3339)
}
out = append(out, PendingNote{
Filename: e.Name(),
CreatedAt: created,
SizeBytes: info.Size(),
Excerpt: excerpt(body, 200),
})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
return out, nil
}
// PromoteOptions identifies a raw note to promote and its wiki destination.
type PromoteOptions struct {
Filename string // basename in brain/raw/
Wing string
Hall string
Slug string // optional; defaults to Filename minus date prefix + .md
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
// note's own frontmatter claims are not provenance (hyperguild#145).
Origin string
WrittenBy string
}
// PromoteNote moves a note from brain/raw/ into the structured wiki: it
// rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at and
// any custom fields), writes to brain/wiki/<wing>/<hall>/<slug>.md, deletes
// the source, then rebuilds the wing index and runs auto-tunnel detection.
//
// It is atomic from the caller's view: validation (hall, wing, slug,
// collision) happens before any filesystem change, and the source is deleted
// only after the destination write succeeds (write-then-delete, never move).
// Returns the promoted note's path relative to brainDir.
func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
// Validate filename (basename only — no traversal) before touching fs.
base := filepath.Base(opts.Filename)
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
return "", fmt.Errorf("invalid filename %q", opts.Filename)
}
slug := opts.Slug
if slug == "" {
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
}
// NotePath validates hall + wing + slug; do this before reading anything.
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
if err != nil {
return "", err
}
src := filepath.Join(brainDir, "raw", base)
raw, err := os.ReadFile(src)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
}
return "", fmt.Errorf("read source: %w", err)
}
// Collision: never silently overwrite an existing promoted note.
if _, statErr := os.Stat(dest); statErr == nil {
rel, _ := filepath.Rel(brainDir, dest)
return "", fmt.Errorf("target %s already exists; choose a different slug", filepath.ToSlash(rel))
}
fm, body := parseFrontmatter(string(raw))
now := time.Now().UTC().Format(time.RFC3339)
fm.set("wing", brain.Sanitise(opts.Wing))
fm.set("hall", opts.Hall)
if fm.get("created_at") == "" {
fm.set("created_at", now)
}
fm.set("promoted_at", now)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create wing dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
if err != nil {
return "", err
}
if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil {
return "", fmt.Errorf("write promoted note: %w", err)
}
if err := os.Remove(src); err != nil {
return "", fmt.Errorf("promoted note written but source removal failed: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
relSlash := filepath.ToSlash(rel)
// Best-effort wiki upkeep — the note is already promoted.
_ = brain.BuildWingIndex(brainDir, opts.Wing)
_ = brain.AutoTunnel(brainDir, relSlash, body)
return relSlash, nil
}
// RejectOptions identifies a raw note to reject and why.
type RejectOptions struct {
Filename string // basename in brain/raw/
Reason string // optional
}
// RejectNote records a human decision not to promote a brain/raw/ note,
// without silently losing it: it stamps rejected_at (+ reject_reason when
// given) into frontmatter and moves the file to brain/raw/rejected/, using
// the same write-then-delete safety PromoteNote uses — the source survives
// any write failure — and the same basename-only filename validation.
//
// A rejected note never entered the wiki, so no index rebuild or
// auto-tunnel run: unlike PromoteNote, this is the end of the note's
// journey, not the start of a new one. It stays out of ListPending's view
// (raw/rejected/ is a subdirectory, and ListPending's os.ReadDir on raw/
// does not recurse), so a rejected note is never re-surfaced as pending —
// the human decision, once made, stays made, the same principle behind
// this estate's "issue status is surfaced, not auto-closed, but once a
// human decides, the decision is recorded" rule applied one level earlier.
func RejectNote(brainDir string, opts RejectOptions) (string, error) {
base := filepath.Base(opts.Filename)
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
return "", fmt.Errorf("invalid filename %q", opts.Filename)
}
src := filepath.Join(brainDir, "raw", base)
raw, err := os.ReadFile(src)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
}
return "", fmt.Errorf("read source: %w", err)
}
dest := filepath.Join(brainDir, "raw", "rejected", base)
if _, statErr := os.Stat(dest); statErr == nil {
rel, _ := filepath.Rel(brainDir, dest)
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
}
fm, body := parseFrontmatter(string(raw))
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
if opts.Reason != "" {
fm.set("reject_reason", opts.Reason)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create rejected dir: %w", err)
}
// Write-then-delete: the source survives any write failure.
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
return "", fmt.Errorf("write rejected note: %w", err)
}
if err := os.Remove(src); err != nil {
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
}
rel, _ := filepath.Rel(brainDir, dest)
return filepath.ToSlash(rel), nil
}
// excerpt returns the first n runes of s, trimmed, single-spaced.
func excerpt(s string, n int) string {
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > n {
r = r[:n]
}
return strings.TrimSpace(string(r))
}