Compare commits

..
Author SHA1 Message Date
mathias 1ff6af4a40 feat(server): say at startup when alerting is unconfigured (#107)
CI / Lint / Test / Vet (pull_request) Successful in 31s
CI / Mirror to GitHub (pull_request) Skipped
buildNotifier returns a nil interface when BRAIN_NTFY_URL is unset, and
every consumer skips a nil notifier silently. That is right for an
unconfigured homelab and wrong for an alert whose entire value is that
someone finds out: "configured and working" and "never configured" were
byte-identical in behaviour — no error, no warning, only a message that
never arrives, which nobody notices because nobody is waiting for an alert
they have never seen.

Two alerts were dark in production for exactly that reason (#100): the
governance-rewrite alert from #97, and the audit-degradation alert that
reports the audit trail is down. Both were found by firing a real event
and watching nothing happen, not by reading anything.

WARN rather than fatal, deliberately. The brain rule
`empty-credential-must-refuse-to-start-not-switch-modes` says an empty
credential must be a startup error rather than a silent mode switch — but
that is about a control being removed. Here observability degrades, no gate
is bypassed, and refusing to boot would break the unconfigured-homelab case
the nil notifier exists to support. What that rule demands and this
provides is that the mode switch stops being silent.

buildAuditSink already announces its own fallback ("capture audit: slog
sink (BRAIN_LOKI_URL unset)"), so only the notifier needed this.

alertingWarnings is pure and env-injected: the reason this gap survived is
that nothing about it was observable, so the fix is tested rather than
asserted.

Refs #107, #100, #97.
2026-08-22 16:10:38 +02:00
9 changed files with 84 additions and 606 deletions
-58
View File
@@ -76,64 +76,6 @@ that prompted the question is answered by naming it here instead.
Full reasoning, including the reversal trigger: brain
`wiki/hyperguild/decisions/brain-mcp-stays-in-ingestion-fix-discoverability-by-name`.
## Ingest revises, it does not overwrite
Re-ingesting a source **merges into the page already on disk** rather than
replacing it. Previously the write was unconditional, so a second ingest of the
same source discarded everything accumulated since — including hand-written
sections.
The merge is deliberately asymmetric in the existing page's favour:
| On the page | In the fresh extraction | Result |
|---|---|---|
| a section | the same section | **the on-disk version wins** |
| — | a new section | added |
| a bullet list (`Sources`, `Key Claims`, …) | more bullets | unioned, no duplicates |
| an append section (`Open Questions`, `Updates`, …) | more lines | only genuinely new lines appended |
| frontmatter | frontmatter | **the on-disk frontmatter wins** |
So an extraction can add to a note but never silently rewrite what a human put
there, which is what makes it safe to run on every ingest rather than behind a
review gate.
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
does not commit noise that hides real edits.
Two fields are **generator-owned** and do get refreshed on a revise:
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
an explicit allowlist, so a field nobody has thought about stays human-owned.
### Disagreement is reported, not resolved
When the fresh extraction produces a *different version* of a section that
already exists, the on-disk version still wins — but the run now says so:
```
wiki/sources/shape-up.md: source now disagrees on section "Summary" — on-disk version kept
```
That line lands in `log.md` with the rest of the run's warnings. Without it, "the
source is unchanged" and "the source changed its mind and we ignored it" look
identical from the outside.
This is deliberately **textual** disagreement, not semantic contradiction: cheap,
deterministic, no model on the write path. Deciding whether a disagreement is a
genuine contradiction stays with the Judge-gated `brain-enrich` pass, which is
approval-gated by design. Detection here, adjudication there.
## Re-ingesting an unchanged source is skipped
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
excluded — on the source page. A later ingest of the same source compares it
first and skips extraction entirely when it matches, reporting the skip rather
than doing it silently.
Body-only is deliberate: a re-crawl that stamps a new fetch date has not changed
the source, and treating that as drift would re-extract the corpus every time a
crawler ran. A missing or unreadable hash always means "extract" — a missed skip
costs one extraction, a wrong skip loses an update.
## Phase 1 tools (available now)
| Tool | What it does |
+35
View File
@@ -147,6 +147,37 @@ func envInt(key string, fallback int) int {
// buildNotifier returns the ntfy notifier, or a nil interface when
// BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so
// DegradingSink/Reconcile and the governance observer skip it cleanly.
// alertingWarnings names every alert that will not fire under the current
// configuration (#107).
//
// buildNotifier returns a nil interface when BRAIN_NTFY_URL is unset, and every
// consumer skips a nil notifier silently — right for an unconfigured homelab,
// wrong for an alert whose entire value is that someone finds out. The cost of
// the silence is that "configured and working" and "never configured" are
// byte-identical in behaviour: no error, no warning, only a message that never
// arrives, which nobody notices because nobody is waiting for an alert they
// have never seen. Two alerts were dark in production for exactly that reason
// (#100), found by firing a real event rather than by reading anything.
//
// WARN rather than fatal, deliberately. The brain sibling rule
// (`empty-credential-must-refuse-to-start-not-switch-modes`) says an empty
// credential must be a startup error, never a silent mode switch — but that is
// about a control being removed. Here observability is degraded, not a gate
// bypassed, and refusing to boot would break the unconfigured-homelab case the
// nil notifier exists to support. What the rule does demand, and what this
// provides, is that the mode switch stops being silent.
//
// Pure and env-injected so the message is testable without a running server:
// the reason this gap survived is that nothing about it was observable.
func alertingWarnings(getenv func(string) string) []string {
var out []string
if getenv("BRAIN_NTFY_URL") == "" {
out = append(out, "alerting DISABLED: BRAIN_NTFY_URL unset — "+
"brain_edit_root governance rewrites and capture audit degradation will not notify")
}
return out
}
func buildNotifier() audit.Notifier {
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n
@@ -203,6 +234,10 @@ func systemHostname() string {
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
for _, w := range alertingWarnings(os.Getenv) {
logger.Warn(w)
}
brainDir := envOr("INGEST_BRAIN_DIR", "../brain")
port := envOr("INGEST_PORT", "3300")
+40
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -36,3 +37,42 @@ func TestClaudeSink_IngestWritesToNonIndexedArchiveNotWiki(t *testing.T) {
_, err = os.Stat(filepath.Join(dir, "wiki", "claude-sessions"))
assert.True(t, os.IsNotExist(err), "raw transcripts must never land under wiki/ (ai-sessions#10 — BM25 pollution)")
}
// --- #107: an unconfigured alert must announce itself ----------------------
func TestAlertingWarnings(t *testing.T) {
tests := []struct {
name string
env map[string]string
want []string // substrings that must appear across the warnings
none bool // true = expect no warnings at all
}{
{
// The failure this exists to prevent: two alerts dark in
// production, discovered only by firing a real event and watching
// nothing arrive (#100).
name: "notifier unconfigured names what will not fire",
env: map[string]string{},
want: []string{"BRAIN_NTFY_URL", "brain_edit_root", "audit"},
},
{
name: "configured notifier is silent",
env: map[string]string{"BRAIN_NTFY_URL": "http://ntfy/brain-alerts"},
none: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := alertingWarnings(func(k string) string { return tt.env[k] })
if tt.none {
assert.Empty(t, got)
return
}
joined := strings.Join(got, " ")
for _, w := range tt.want {
assert.Contains(t, joined, w)
}
})
}
}
-3
View File
@@ -76,9 +76,6 @@ func buildFrontmatter(rp RawPage, date string) string {
}
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
fmt.Fprintf(&sb, "last_updated: %s\n", date)
if rp.SourceHash != "" {
fmt.Fprintf(&sb, "source_hash: %s\n", rp.SourceHash)
}
if rp.Source != "" {
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
}
+3 -13
View File
@@ -20,13 +20,9 @@ type RawPage struct {
// ingested content's own frontmatter (see parseContentFrontmatter) — never
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
// might emit. Only meaningful for Type == "source".
// SourceHash is sha256 of the ingested BODY (frontmatter excluded), stamped
// so the next ingest of the same source can tell "unchanged" from "edited"
// without re-running extraction (#105).
SourceHash string `json:"-"`
Source string `json:"-"`
Author string `json:"-"`
Published string `json:"-"`
Source string `json:"-"`
Author string `json:"-"`
Published string `json:"-"`
}
// ParseRawPages parses LLM output as a JSON array of RawPage objects.
@@ -116,11 +112,6 @@ type sourceMeta struct {
Source string
Author string
Published string
// BodyHash is sha256 of the content with any leading frontmatter removed.
// Body-only on purpose: a re-crawl that stamps a new fetch date has not
// changed the source, and treating that as drift would re-extract the
// whole corpus every time a crawler ran (#105).
BodyHash string
}
// parseContentFrontmatter extracts source/author/published from a leading
@@ -163,7 +154,6 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) {
if pages[i].Type != "source" {
continue
}
pages[i].SourceHash = meta.BodyHash
pages[i].Source = meta.Source
pages[i].Author = meta.Author
pages[i].Published = meta.Published
+2 -105
View File
@@ -3,8 +3,6 @@ package pipeline
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
@@ -28,11 +26,6 @@ type Config struct {
type Result struct {
Pages []string // relative paths written (or would-be written in dry-run)
Warnings []string
// Skipped reports that the source was byte-identical to the last ingest,
// so no extraction ran and nothing was written. Reported rather than
// silent: a run that does nothing must say so, or "the pipeline is fine"
// and "the pipeline stopped working" look the same (#105, #107).
Skipped bool
}
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
@@ -50,18 +43,6 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
sourceSlug := wiki.Slug(source)
date := time.Now().UTC().Format("2006-01-02")
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
// pass over every chunk to produce a result that merges to nothing (#105).
// The merge is already idempotent (#103), so this is cost and signal, not
// correctness — which is why it is safe for it to be an early return.
bodyHash := hashBody(content)
if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) {
return Result{Skipped: true, Warnings: []string{
fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]),
}}, nil
}
chunks := Chunk(content, cfg.ChunkSize)
var allRaw []RawPage
@@ -78,9 +59,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
allWarnings = append(allWarnings, warnings...)
}
meta := parseContentFrontmatter(content)
meta.BodyHash = bodyHash
applySourceMeta(allRaw, meta)
applySourceMeta(allRaw, parseContentFrontmatter(content))
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
}
@@ -117,19 +96,7 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
}
content, conflicts, err := reviseOrCreate(dest, page)
if err != nil {
return Result{}, err
}
for _, heading := range conflicts {
// Reported, not resolved. The on-disk version stands; whether
// the disagreement is a genuine contradiction is the
// Judge-gated enrich pass's call, not the write path's.
warnings = append(warnings, fmt.Sprintf(
"%s: source now disagrees on section %q — on-disk version kept",
page.Path, heading))
}
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
}
}
@@ -148,76 +115,6 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
return Result{Pages: written, Warnings: warnings}, nil
}
// hashBody returns sha256 of content with any leading frontmatter block
// removed, or "" for empty content.
//
// Body-only on purpose: a re-crawl that stamps a new fetch date has not changed
// the source, and treating that as drift would re-extract the corpus every time
// a crawler ran (#105).
func hashBody(content string) string {
body := content
if strings.HasPrefix(body, "---\n") {
if _, rest, found := strings.Cut(body[4:], "\n---"); found {
body = strings.TrimPrefix(rest, "\n")
}
}
if strings.TrimSpace(body) == "" {
return ""
}
sum := sha256.Sum256([]byte(body))
return hex.EncodeToString(sum[:])
}
// storedSourceHash reads source_hash from the source page a previous ingest
// wrote, or "" when there is no such page or no such field.
//
// Absence always means "extract". That is the fail-safe direction: a missed
// skip costs one extraction, while a wrong skip loses an update silently — and
// silence is the failure mode this repo keeps paying for.
func storedSourceHash(brainDir, sourceSlug string) string {
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md"))
if err != nil {
return ""
}
for _, line := range strings.Split(string(raw), "\n") {
if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" {
return strings.TrimSpace(value)
}
}
return ""
}
// reviseOrCreate returns the bytes to write for one extracted page: merged into
// whatever is already on disk, or the page as-is when the path is new.
//
// This is the compile half of compile-on-ingest. Before it, the write was an
// unconditional os.WriteFile, so re-ingesting a source replaced its page
// wholesale and discarded everything accumulated since — including human edits
// (hyperguild#43: "re-ingest overwrites wholesale").
//
// The merge is deliberately asymmetric in the existing page's favour:
// wiki.Merge keeps the on-disk frontmatter and the on-disk version of any
// section both sides carry, adds sections only the extraction produced, and
// unions bullet lists. So a freshly extracted page can add to a note but never
// silently rewrite what a human put there — which is what makes it safe to run
// this on every ingest rather than behind a review gate.
//
// Known limitation: because the on-disk frontmatter wins, `last_updated` keeps
// its first-ingest date on a revised page. Left deliberately — the alternative
// is this function editing frontmatter it does not own, and no lint reads that
// field today.
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
existing, err := os.ReadFile(dest)
switch {
case os.IsNotExist(err):
return page.Content, nil, nil
case err != nil:
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
}
merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page)
return merged.Content, conflicts, nil
}
// mergeAll deduplicates pages by path, merging content from later occurrences.
func mergeAll(pages []wiki.Page) []wiki.Page {
order := make([]string, 0, len(pages))
@@ -8,7 +8,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -172,156 +171,3 @@ func mustJSON(v any) string {
}
return string(b)
}
// --- B1: compile-on-ingest — revise the existing page, never clobber it -----
func TestRunRaw_MergesIntoAnExistingPageInsteadOfOverwritingIt(t *testing.T) {
// Before this, buildAndWrite did an unconditional os.WriteFile: re-ingesting
// a source replaced its page wholesale, discarding anything accumulated
// since — including human edits. hyperguild#43 recorded the symptom as
// "re-ingest overwrites wholesale".
dir := t.TempDir()
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
require.NoError(t, os.WriteFile(page, []byte(`---
title: 'Shape Up'
---
## Summary
A book about shaping product work.
## My Take
Read this before the next planning cycle.
`), 0o644))
_, err := RunRaw(dir, "shape-up", []RawPage{{
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
}}, false)
require.NoError(t, err)
got, err := os.ReadFile(page)
require.NoError(t, err)
s := string(got)
assert.Contains(t, s, "## My Take", "a section the extraction never saw must survive the merge")
assert.Contains(t, s, "Read this before the next planning cycle.")
assert.Contains(t, s, "Appetite beats estimation.", "newly extracted material must still land")
}
func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
// Compile-on-ingest runs on every ingest, so "nothing changed" must produce
// no diff at all. Otherwise every re-ingest dirties the tree, brain-sync
// commits it, and the git history fills with noise that hides real edits.
dir := t.TempDir()
raw := []RawPage{{
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
}}
_, err := RunRaw(dir, "shape-up", raw, false)
require.NoError(t, err)
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
require.NoError(t, err)
_, err = RunRaw(dir, "shape-up", raw, false)
require.NoError(t, err)
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
require.NoError(t, err)
assert.Equal(t, string(first), string(second),
"re-ingesting unchanged content must leave the page byte-identical")
}
// --- #105: source drift — an unchanged re-ingest must not re-extract --------
func TestRun_SkipsExtractionWhenTheSourceIsUnchanged(t *testing.T) {
// #103 made a re-ingest harmless (byte-identical merge). It is still not
// free: the full LLM extraction runs over every chunk to produce a result
// that merges to nothing. This is the cost half.
dir := t.TempDir()
var calls int
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
calls++
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
}}
content := "---\nsource: https://example.com/shape-up\n---\n\nThe body of the article.\n"
first, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
require.NoError(t, err)
require.Equal(t, 1, calls, "first ingest must extract")
assert.False(t, first.Skipped)
second, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
require.NoError(t, err)
assert.Equal(t, 1, calls, "unchanged content must not be extracted again")
assert.True(t, second.Skipped, "the skip must be reported, not silent")
}
func TestRun_ReExtractsWhenTheSourceBodyChanged(t *testing.T) {
dir := t.TempDir()
var calls int
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
calls++
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
}}
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion one\n", "shape-up", false)
require.NoError(t, err)
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion two — the source was edited\n", "shape-up", false)
require.NoError(t, err)
assert.Equal(t, 2, calls, "changed content must be re-extracted")
}
func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
// The hash covers the body, so re-fetching a page whose metadata moved
// (a re-crawl stamping a new date) is not treated as the source changing.
dir := t.TempDir()
var calls int
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
calls++
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
}}
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: monday\n---\n\nsame body\n", "shape-up", false)
require.NoError(t, err)
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: tuesday\n---\n\nsame body\n", "shape-up", false)
require.NoError(t, err)
assert.Equal(t, 1, calls, "only the body counts as the source")
}
func TestRunRaw_ReportsWhenTheSourceDisagreesWithWhatIsOnDisk(t *testing.T) {
// The on-disk version still wins — that is what makes unattended ingest
// safe. But the run must say that the source now claims something else,
// or a changed source is indistinguishable from an unchanged one.
dir := t.TempDir()
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
require.NoError(t, os.WriteFile(page, []byte(`---
title: 'Shape Up'
---
## Summary
Appetite is fixed, scope is variable.
`), 0o644))
res, err := RunRaw(dir, "shape-up", []RawPage{{
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product",
Content: "## Summary\n\nAppetite is negotiable and scope is fixed.\n",
}}, false)
require.NoError(t, err)
got, err := os.ReadFile(page)
require.NoError(t, err)
assert.Contains(t, string(got), "Appetite is fixed", "on-disk version wins")
joined := strings.Join(res.Warnings, " ")
assert.Contains(t, joined, "Summary", "the disagreeing section must be named")
assert.Contains(t, joined, "shape-up", "and the page it happened on")
}
+4 -130
View File
@@ -32,33 +32,10 @@ type section struct {
// Frontmatter is taken from a. Sections are merged by strategy:
// bullet sections union unique lines, append sections concatenate,
// all others keep a's version. Sections in b not present in a are appended.
// Merge combines two pages, discarding any disagreement silently.
// Prefer MergeReport where the caller can act on the disagreement.
func Merge(a, b Page) Page {
page, _ := MergeReport(a, b)
return page
}
// MergeReport is Merge, plus the headings of sections where b carried a
// different version of a section a already had — and lost.
//
// That loss is the property which makes ingest safe to run unattended (#103):
// a regenerating writer can add to a note but never rewrite it. But the
// discarded text is also a signal, and until now it went in the bin: the source
// now says something different about a section we already hold. Nobody could
// tell "the source is unchanged" from "the source changed its mind and we
// ignored it".
//
// Deliberately NOT a semantic contradiction check. This reports textual
// disagreement, which is cheap, deterministic, and needs no model on the write
// path. Deciding whether a disagreement is a genuine contradiction stays with
// the Judge-gated enrich pass that already exists (brain-gardener#175), which
// is approval-gated by design — detection here, adjudication there.
func MergeReport(a, b Page) (Page, []string) {
fmA, secsA := parseSections(a.Content)
fmB, secsB := parseSections(b.Content)
_, secsB := parseSections(b.Content)
var conflicts []string
idx := make(map[string]int, len(secsA))
for i, s := range secsA {
idx[s.heading] = i
@@ -76,65 +53,11 @@ func MergeReport(a, b Page) (Page, []string) {
case bulletSections[sB.heading]:
secsA[i].content = mergeBullets(sA.content, sB.content)
case appendSections[sB.heading]:
secsA[i].content = appendNew(sA.content, sB.content)
default:
// a wins — but say that b disagreed, rather than dropping it mute.
if strings.TrimSpace(sA.content) != strings.TrimSpace(sB.content) {
conflicts = append(conflicts, sB.heading)
}
secsA[i].content = strings.TrimRight(sA.content, "\n") + "\n\n" + strings.TrimLeft(sB.content, "\n")
}
}
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), secsA)}, conflicts
}
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
// on a page it is revising. Everything else on disk wins.
//
// #103 made the on-disk frontmatter win wholesale, which is what stops a
// regenerating writer from overwriting human-set metadata — a title someone
// fixed, or the claim-property envelope (brain#21). The cost was that fields
// the generator legitimately owns froze at their first-ingest values: when it
// last ran, and what it last saw.
//
// An explicit allowlist rather than a carve-out: the ownership boundary is
// nameable, and a field nobody has thought about stays human-owned by default,
// which is the safe direction.
var generatorOwned = map[string]bool{
"last_updated": true,
"source_hash": true,
}
// refreshOwned returns a's frontmatter with the generator-owned fields taken
// from b where b supplies them. Fields absent from b keep a's value, so a
// generator that stops emitting a field cannot blank it.
func refreshOwned(a, b string) string {
bVals := make(map[string]string)
for _, line := range strings.Split(b, "\n") {
key, value, found := strings.Cut(line, ":")
if !found || !generatorOwned[strings.TrimSpace(key)] {
continue
}
bVals[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
if len(bVals) == 0 {
return a
}
out := make([]string, 0, len(strings.Split(a, "\n")))
for _, line := range strings.Split(a, "\n") {
key, _, found := strings.Cut(line, ":")
k := strings.TrimSpace(key)
if found && generatorOwned[k] {
if v, ok := bVals[k]; ok {
out = append(out, k+": "+v)
delete(bVals, k)
continue
}
}
out = append(out, line)
}
return strings.Join(out, "\n")
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)}
}
func parseSections(markdown string) (frontmatter string, sections []section) {
@@ -173,64 +96,15 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
return
}
// rebuildContent serialises sections back into a page.
//
// Each section body is normalised to exactly one trailing newline and no
// leading blank line. Without that, a section keeps whatever blank lines it was
// parsed with and this function adds its own, so every merge pass grows the page
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
// merges on every run, so "merging changes nothing when nothing changed" has to
// hold at the byte level, not just the content level.
func rebuildContent(frontmatter string, sections []section) string {
var sb strings.Builder
sb.WriteString(frontmatter)
for _, sec := range sections {
body := strings.Trim(sec.content, "\n")
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
fmt.Fprintf(&sb, "\n## %s\n\n%s", sec.heading, sec.content)
}
return sb.String()
}
// appendNew concatenates the lines of b that a does not already carry.
//
// Append sections used to concatenate wholesale, which is correct exactly once.
// Compile-on-ingest merges a freshly extracted page into the one already on
// disk, so an unchanged source re-ingested would grow "Open Questions" and
// "Updates" on every run until the page is unreadable — and the drift guard
// that would otherwise catch it (hashing the source body) does not exist yet,
// so idempotency has to hold here rather than upstream.
//
// Line-level rather than block-level: the common case is a re-ingest carrying
// one genuinely new line alongside several it already has. A block-level
// "skip if identical" would duplicate the old lines every time that happened.
//
// The cost is that a line repeated verbatim on purpose collapses to one. For
// these sections — questions, updates, evolving notes — a repeat is noise
// rather than signal, and the failure is conservative: the page stops growing
// instead of growing wrongly.
func appendNew(a, b string) string {
seen := make(map[string]bool)
for _, line := range strings.Split(a, "\n") {
if t := strings.TrimSpace(line); t != "" {
seen[t] = true
}
}
var add []string
for _, line := range strings.Split(b, "\n") {
t := strings.TrimSpace(line)
if t == "" || seen[t] {
continue
}
seen[t] = true
add = append(add, line)
}
if len(add) == 0 {
return a // nothing new — leave the section byte-identical
}
return strings.TrimRight(a, "\n") + "\n\n" + strings.Join(add, "\n") + "\n"
}
func mergeBullets(a, b string) string {
seen := make(map[string]bool)
var lines []string
-143
View File
@@ -53,146 +53,3 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
assert.Contains(t, got.Content, "title: A")
assert.NotContains(t, got.Content, "title: B")
}
// --- brain#21/B1: merging must be safe to run again -------------------------
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
// Compile-on-ingest merges a freshly extracted page into the one already
// on disk. Bullet sections union, so they are naturally idempotent — but
// append sections concatenate, so re-ingesting an unchanged source would
// grow "Open Questions" on every run until the page is unreadable.
page := Page{Path: "wiki/sources/x.md", Content: `---
title: X
---
## Open Questions
- Does this converge?
`}
once := Merge(page, page)
twice := Merge(once, page)
assert.Equal(t, 1, strings.Count(once.Content, "Does this converge?"),
"merging a page with itself must not duplicate an append section")
assert.Equal(t, once.Content, twice.Content,
"merge must be idempotent — a third pass may not keep growing the page")
}
func TestMerge_AppendSectionStillAccumulatesGenuinelyNewContent(t *testing.T) {
// The dedup must not turn append sections into keep-a-only sections:
// new material is exactly what compile-on-ingest exists to add.
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: X
---
## Open Questions
- Does this converge?
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
title: X
---
## Open Questions
- What happens under load?
`}
got := Merge(existing, fresh).Content
assert.Contains(t, got, "Does this converge?", "existing question must survive")
assert.Contains(t, got, "What happens under load?", "new question must be added")
}
func TestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshed(t *testing.T) {
// #103 made on-disk frontmatter win wholesale, which is what protects
// human-set fields from a regenerating writer. The cost was that fields the
// generator legitimately owns — when it last ran, what it last saw — froze
// at their first-ingest values. An explicit owned-set is the boundary:
// named fields the generator may refresh, everything else human-owned.
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
last_updated: 2026-01-01
source_hash: aaaa
half_life: durable
claim_provenance: asserted
---
## Summary
Original.
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
title: 'Renamed by the model'
last_updated: 2026-08-22
source_hash: bbbb
---
## Summary
Rewritten by the model.
`}
got := Merge(existing, fresh).Content
assert.Contains(t, got, "last_updated: 2026-08-22", "generator owns last_updated")
assert.Contains(t, got, "source_hash: bbbb", "generator owns source_hash")
assert.Contains(t, got, "title: 'X'", "a human-set title must survive regeneration")
assert.Contains(t, got, "half_life: durable", "the claim envelope must survive regeneration")
assert.Contains(t, got, "claim_provenance: asserted")
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
assert.NotContains(t, got, "Renamed by the model")
}
func TestMerge_ReportsSectionsWhereTheFreshVersionWasDiscarded(t *testing.T) {
// The asymmetry that makes ingest safe (on-disk wins) also throws away a
// real signal: the source now says something different about a section we
// already have. Today that disagreement is silently discarded, so nobody
// can tell "the source is unchanged" from "the source changed its mind and
// we ignored it".
existing := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is fixed, scope is variable.
## Method
Six-week cycles.
`}
fresh := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is negotiable and scope is fixed.
## Method
Six-week cycles.
`}
got, conflicts := MergeReport(existing, fresh)
assert.Contains(t, got.Content, "Appetite is fixed", "on-disk version still wins")
assert.NotContains(t, got.Content, "negotiable")
assert.Equal(t, []string{"Summary"}, conflicts,
"only the section that actually disagreed is reported")
}
func TestMerge_ReportsNothingWhenTheFreshVersionAgrees(t *testing.T) {
page := Page{Path: "wiki/sources/x.md", Content: `---
title: 'X'
---
## Summary
Appetite is fixed, scope is variable.
`}
_, conflicts := MergeReport(page, page)
assert.Empty(t, conflicts, "identical content is not a disagreement")
}