Compare commits

..
Author SHA1 Message Date
mathiasandClaude Opus 5 36b757fabf feat(mcp): add brain_read + brain_edit, contained to the sync source (brain#4)
CI / Lint / Test / Vet (pull_request) Successful in 31s
CI / Mirror to GitHub (pull_request) Skipped
brain_read returns a note verbatim; brain_edit is str_replace with an
exactly-once match. Both operate on INGEST_BRAIN_DIR, which is the pod's
mount of /var/lib/supervisor/brain — the tree brain-sync commits from, so
an edit reaches gitea on the next cycle rather than landing somewhere the
sync cannot see it (brain#2).

An adversarial design review before implementation changed three things,
each of which would otherwise have shipped:

1. Governance files are NOT in the model-facing tool. The original plan
   allowed root *.md so CLAUDE.md had an MCP path at all — the absence of
   one is what pushed a session to edit via the gitea REST API on
   2026-06-16 and wedge the sync. But brain content is ingested from the
   outside world, reaches a model's context via brain_query, and that
   model would hold the editor: one poisoned page could rewrite the
   CLAUDE.md a .dispatch-allow agent later loads as instructions. Worse,
   brain-sync merges with --strategy-option=ours, so the live tree wins
   against a human's corrective push on exactly the conflicting lines.
   CLAUDE.md now goes through brain_edit_root: de-surfaced from
   tools/list (the brain_index precedent, ADR-0012 rule 4) and gated on
   the local static principal, so a claude.ai-origin session is refused.
   The two scopes are disjoint — the privileged door has the smaller reach.

2. Reads needed the same allowlist, and brain_get already lacked it.
   api.ReadNote resolves any brain-relative path and parseFrontmatter
   returns the whole file when there is no fence, so brain_get reads
   verbatim session transcripts under archive/ (up to 500 KB, never
   BM25-indexed, so nobody thinks of them as reachable). A test proves
   the exposure was live; the same Resolve now gates brain_get.

3. Canonicalise before allowlisting. A prefix check on the raw argument
   accepts knowledge/../.git/hooks/post-commit, which Clean collapses to
   a git hook that brain-sync executes inside a pod holding the Gitea
   push credentials. Traversal, symlink escape, dotfiles, .git/, archive/,
   sessions/ and raw/ are refused by a static backstop that does not
   depend on the allowlist being right. There were no traversal tests in
   this repo before.

Also from the review: overlapping match counting (bytes.Count says "aa"
occurs once in "aaa", so a Count-based uniqueness check accepts an
ambiguous edit); UTF-8 validation on both sides, because encoding/json
substitutes U+FFFD and would hand back bytes the file does not contain,
making every derived old_str unmatchable; refusal while .git/MERGE_HEAD
or index.lock exists, since an edit racing brain-sync's merge is either
hard-reset away or committed as a wrongly resolved conflict; flock plus a
per-path mutex with the read taken inside the lock, because two pods
share the hostPath during a RollingUpdate and uniqueness evaluated
against a stale read loses an update while reporting success; same-dir
*.tmp + rename + chmod 0644 + fsync of file and parent; and reindex only
when the edit touches frontmatter, since BM25 walks live and embeddings
are mtime-driven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-18 17:01:37 +02:00
mathiasandClaude Opus 5 db00644cab fix(capture): name what escalated the classification in an I1 refusal (#91)
CI / Lint / Test / Vet (pull_request) Successful in 30s
CI / Mirror to GitHub (pull_request) Skipped
infra#265 reported "capture with a tickets array fails": 20 tickets
errored, 5 errored, the caller fell back to 20 individual issue_create
calls and filed a bug against the array.

The array was never the failing dimension. Every ticket in that batch
targeted a client repo, and the I1 sovereignty gate refuses a
confidential capture through a us-nexus origin at ANY length — reproduced
live today against brain-mcp: length 1 succeeds for an internal repo,
length 20 validates clean, and a single ticket targeting
trucker-ecommerce is refused.

What made it look like an array bug is that the refusal named neither the
offending target nor the fact that a policy said no:

    capture refused by I1 sovereignty gate: effective classification
    confidential through us-nexus origin

With twenty tickets a caller cannot tell which repo tripped it, so
"the array is broken" is a reasonable conclusion from the evidence
available. resolveClassification now also returns the targets carrying
the effective level, and the refusal names them:

    ... through us-nexus origin (required by: trucker-ecommerce)

Only targets that actually reach the effective level are named — at
twenty tickets, listing the innocent ones hides the one that matters —
and a caller who declared confidential themselves is told exactly that,
because "you asked for this" and "a repo you touched required it" call
for different fixes.

Regression tests at length 1 and 20 pin the array path that was never
broken, and a third pins per-item partial success: infra#265 asked "if
ticket 3 of 5 fails, what happened to 1 and 2?" — they land, and the
receipt says so per item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-18 15:38:12 +02:00
15 changed files with 46 additions and 954 deletions
-2
View File
@@ -1,2 +0,0 @@
# Presence of this file marks this repo as opt-in for headless dispatch.
# See dispatch#3.
+3 -62
View File
@@ -5,11 +5,6 @@ Instead of letting Claude Code do whatever it wants, hyperguild enforces structu
workflows (TDD red/green/refactor), logs every session, and accumulates learnings
into a searchable brain.
**This repo is also where `brain-mcp` lives** — the MCP server at
`brain-mcp.d-ma.be` that `brain_query`, `brain_write`, `brain_read`, `brain_edit`
and `capture` are served from. It is not a separate deployable; see
[Where brain-mcp lives](#where-brain-mcp-lives).
## Hypothesis
> We believe routing skill tasks through local models, backed by brain context,
@@ -25,7 +20,7 @@ usage, the fast-model routing path should be reconsidered.
**hyperguild = Claude Code + MCP.** This is a supervisor for Claude Code sessions specifically.
For multi-agent orchestration (OpenCode + LiteLLM, executor/reviewer pipelines), see
[agentsquad](https://git.d-ma.be/mathias/agentsquad) — a separate harness for a different
[agentsquad](http://gitea.d-ma.be/mathias/agentsquad) — a separate harness for a different
orchestration model. Skills (mathias/skills) are shared between both.
## How it works
@@ -49,60 +44,6 @@ brain/
└── knowledge/ — legacy flat notes (migration pending: hyperguild#22)
```
## Where brain-mcp lives
`brain-mcp` is served from **`ingestion/internal/mcp`** in this repo, at
**`brain-mcp.d-ma.be`**. It is a component *inside* the `ingestion` service, not
a standalone deployable — one pod in the `supervisor` namespace serves the MCP
endpoint, the REST API, and the claudewatcher together.
| You are looking for | It is here |
|---|---|
| The MCP tool surface (`brain_query`, `brain_write`, `brain_read`, `brain_edit`, `capture`, …) | `ingestion/internal/mcp` |
| The HTTP/REST API (`/query`, `/write`, `/ingest`) | `ingestion/internal/api` |
| Session ingestion from Claude Code transcripts | `ingestion/internal/claudewatcher` |
| The brain *content* (notes, wings, halls) | [`mathias/brain`](https://git.d-ma.be/mathias/brain) — a separate repo |
| Deployment manifests | `config/supervisor` |
### Why it is not its own repo
Asked and decided in [#91](https://git.d-ma.be/mathias/hyperguild/issues/91)
(part 2). `internal/mcp` imports 17 of ingestion's other internal packages and
has exactly one importer — it is the MCP facade over the service's own
internals, so "move it out" resolves to renaming `ingestion`, publishing 17
packages as a shared library, or maintaining a shim. The discoverability problem
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.
Known limitation: because the on-disk frontmatter wins, `last_updated` keeps its
first-ingest date on a revised page.
## Phase 1 tools (available now)
| Tool | What it does |
@@ -117,7 +58,7 @@ first-ingest date on a revised page.
| `tier` | Returns the current connectivity tier (1=cloud, 2=LAN, 3=offline) |
> **Note:** `tdd_red/green/refactor` and `spec` were retired in Plan 7 (2026-05-12).
> They are now SKILL.md files in [mathias/skills](https://git.d-ma.be/mathias/skills).
> They are now SKILL.md files in [mathias/skills](http://gitea.d-ma.be/mathias/skills).
## Connect a project
@@ -206,7 +147,7 @@ The routing pod selects models per skill call based on historical pass rate:
## Open issues
See [issues](https://git.d-ma.be/mathias/hyperguild/issues) — key open items:
See [issues](http://gitea.d-ma.be/mathias/hyperguild/issues) — key open items:
- **#25** — skills platform overhaul (audit first, then lazy loading + brain feedback loop)
- **#24** — reduce context burn from skill listing
+7 -18
View File
@@ -144,16 +144,6 @@ func envInt(key string, fallback int) int {
return fallback
}
// 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.
func buildNotifier() audit.Notifier {
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
return n
}
return nil
}
// buildAuditSink selects the capture audit sink. When BRAIN_LOKI_URL is
// set it builds the classification-aware DegradingSink (loki central +
// durable file buffer + optional ntfy) and starts the reconcile loop;
@@ -171,7 +161,12 @@ func buildAuditSink(ctx context.Context, brainDir string, logger *slog.Logger) c
logger.Error("capture audit buffer init", "err", err)
os.Exit(1)
}
notifier := buildNotifier()
// Keep notifier as a nil interface (not a typed-nil) when unconfigured
// so DegradingSink/Reconcile skip it cleanly.
var notifier audit.Notifier
if n := audit.NewNtfyNotifier(os.Getenv("BRAIN_NTFY_URL"), os.Getenv("BRAIN_NTFY_TOKEN")); n != nil {
notifier = n
}
reconcileInterval := time.Duration(envInt("BRAIN_AUDIT_RECONCILE_INTERVAL", 60)) * time.Second
audit.StartReconcile(ctx, central, buffer, notifier, reconcileInterval)
logger.Info("capture audit: loki+buffer sink", "loki", lokiURL, "reconcile_s", int(reconcileInterval.Seconds()))
@@ -495,13 +490,7 @@ func main() {
// the existing /mcp OAuth connector. mcpSrv is already wrapped above;
// WithCapture mutates the same instance, so the tool appears live.
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
// hyperguild#97: a governance-file rewrite gets the same trail a
// capture already gets, plus an out-of-band alert. Wired here because
// brain_edit_root needs the capture plumbing anyway — that is what
// re-derives the static principal its gate checks.
mcpSrv.WithGovernanceObserver(auditSink, buildNotifier())
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign),
"governance_observer", true)
logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign))
} else {
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
}
+4 -33
View File
@@ -12,7 +12,6 @@ import (
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
@@ -153,24 +152,15 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create hall dir: %w", err)
}
existingFields, body := splitFrontmatter(opts.Content)
existingByKey := make(map[string]frontmatterField, len(existingFields))
for _, f := range existingFields {
existingByKey[f.key] = f
}
// Validate the claim-property envelope BEFORE creating the directory, so a
// rejected write leaves nothing behind — not even an empty hall dir
// (brain#21). Fail closed: a value outside the enum is worse than an absent
// one, because a lint skips what it cannot parse and the note then reads as
// classified while nothing acts on it.
if err := claim.ValidateEnvelope(scalarValues(existingFields)); err != nil {
return "", fmt.Errorf("claim envelope: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("create hall dir: %w", err)
}
emitted := make(map[string]bool, 6)
var fm strings.Builder
@@ -228,25 +218,6 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
return filepath.ToSlash(rel), nil
}
// scalarValues flattens frontmatter fields to key -> scalar value for
// validation. Multi-line values (lists, nested blocks) yield the text after
// the colon on the first line, which is empty for a list — correct here,
// since every claim-property field is a scalar enum.
func scalarValues(fields []frontmatterField) map[string]string {
out := make(map[string]string, len(fields))
for _, f := range fields {
if len(f.lines) == 0 {
continue
}
_, value, found := strings.Cut(f.lines[0], ":")
if !found {
continue
}
out[f.key] = strings.TrimSpace(value)
}
return out
}
// frontmatterField is one top-level YAML key from a frontmatter block,
// along with its raw line and any indented continuation lines (e.g. a
// bulleted list value spanning multiple lines).
-57
View File
@@ -440,60 +440,3 @@ func TestIngestPath_Directory(t *testing.T) {
require.True(t, ok, "pages must be an array")
assert.NotEmpty(t, pagesSlice)
}
// --- brain#21: claim-property envelope on the write path -------------------
func TestWriteNote_PreservesClaimEnvelopeVerbatim(t *testing.T) {
// writeHallNote already passes unknown keys through; this pins that
// behaviour for the envelope specifically, so a future refactor of the
// merge loop cannot silently drop it.
dir := t.TempDir()
rel, err := api.WriteNote(dir, api.WriteNoteOptions{
Content: "---\nhalf_life: durable\napplies: universal\nclaim_provenance: asserted\n---\n\nA durable claim.\n",
Wing: "homelab",
Hall: "decisions",
})
require.NoError(t, err)
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
require.NoError(t, err)
assert.Contains(t, string(got), "half_life: durable")
assert.Contains(t, string(got), "applies: universal")
assert.Contains(t, string(got), "claim_provenance: asserted")
}
func TestWriteNote_RejectsInvalidClaimEnvelopeAndWritesNothing(t *testing.T) {
// Fail closed: a value outside the enum must not land. A lint skips what
// it cannot parse, so an unvalidated write produces a note that reads as
// classified while nothing acts on it.
dir := t.TempDir()
_, err := api.WriteNote(dir, api.WriteNoteOptions{
Content: "---\nhalf_life: forever\nclaim_provenance: asserted\n---\n\nBody.\n",
Filename: "bad-envelope",
Wing: "homelab",
Hall: "facts",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "half_life")
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/bad-envelope.md"))
assert.True(t, os.IsNotExist(statErr), "a rejected write must leave no file behind")
}
func TestWriteNote_RejectsClaimFieldWithoutProvenance(t *testing.T) {
dir := t.TempDir()
_, err := api.WriteNote(dir, api.WriteNoteOptions{
Content: "---\napplies: homelab\n---\n\nBody.\n",
Filename: "no-provenance",
Wing: "homelab",
Hall: "facts",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "claim_provenance")
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/no-provenance.md"))
assert.True(t, os.IsNotExist(statErr))
}
-98
View File
@@ -1,98 +0,0 @@
// Package claim validates the claim-property frontmatter that says when and
// where a note's claim holds, and who decided that (brain#21).
//
// Two families were designed together and land separately. This is the
// applicability envelope:
//
// half_life: volatile | seasonal | durable — how fast it decays
// applies: local | homelab | universal — how widely it holds
// claim_provenance: asserted | inferred — who set the above
//
// The strength family (confidence, contested) follows once the ingest-time
// contradiction detector exists to populate it.
//
// Why the envelope is validated rather than merely accepted: a value outside
// the enum is worse than an absent one. A lint skips what it cannot parse, so
// the note reads as classified while nothing acts on it — the field becomes
// decoration that suppresses the very review it was added to trigger.
//
// Why `applies` and not `scope`: `scope:` has been in use since brain#5 for
// glossary terms (`homelab-core`, `project:<repo>`) — a disjoint value space
// on the same key. The shipped schema keeps the name; this one takes another.
package claim
import (
"fmt"
"strings"
)
// Field names, exported so writers and lints agree on the spelling.
const (
FieldHalfLife = "half_life"
FieldApplies = "applies"
FieldProvenance = "claim_provenance"
)
var (
halfLives = []string{"volatile", "seasonal", "durable"}
appliesTo = []string{"local", "homelab", "universal"}
provenances = []string{"asserted", "inferred"}
)
// ValidateEnvelope checks the envelope fields of one note's frontmatter.
//
// Absent fields are valid: the envelope is opt-in for new writes, and a
// corpus-wide backfill is deliberately out of scope (brain#21) — inferring
// 1113 notes' properties in bulk has the same failure mode as the extraction
// pass that produced 28 wrong wikilinks out of 67.
func ValidateEnvelope(fm map[string]string) error {
halfLife, hasHalfLife := present(fm, FieldHalfLife)
applies, hasApplies := present(fm, FieldApplies)
prov, hasProv := present(fm, FieldProvenance)
if hasHalfLife {
if err := oneOf(FieldHalfLife, halfLife, halfLives); err != nil {
return err
}
}
if hasApplies {
if err := oneOf(FieldApplies, applies, appliesTo); err != nil {
return err
}
}
if hasProv {
if err := oneOf(FieldProvenance, prov, provenances); err != nil {
return err
}
}
// The field and its provenance travel together or not at all. A value
// whose author is unknown cannot be trusted differently from one Mathias
// asserted, which is how an inferred guess hardens into accepted fact.
switch {
case (hasHalfLife || hasApplies) && !hasProv:
return fmt.Errorf("%s is required when %s or %s is set",
FieldProvenance, FieldHalfLife, FieldApplies)
case hasProv && !hasHalfLife && !hasApplies:
return fmt.Errorf("%s is set but neither %s nor %s is",
FieldProvenance, FieldHalfLife, FieldApplies)
}
return nil
}
// present reports a trimmed field value and whether it was set to anything
// non-empty. A key written with an empty value is treated as absent rather
// than as an error: that is a caller emitting a placeholder, not asserting.
func present(fm map[string]string, key string) (string, bool) {
v := strings.TrimSpace(fm[key])
return v, v != ""
}
func oneOf(field, value string, allowed []string) error {
for _, a := range allowed {
if a == value {
return nil
}
}
return fmt.Errorf("%s: %q is not one of %s", field, value, strings.Join(allowed, ", "))
}
-89
View File
@@ -1,89 +0,0 @@
// Package claim_test covers the claim-property envelope (brain#21).
package claim_test
import (
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateEnvelope(t *testing.T) {
tests := []struct {
name string
fm map[string]string
wantErr string
}{
{
name: "absent is fine — the fields are opt-in on new writes",
fm: map[string]string{"wing": "homelab", "hall": "facts"},
},
{
name: "complete envelope with provenance",
fm: map[string]string{
"half_life": "durable", "applies": "universal", "claim_provenance": "asserted",
},
},
{
name: "inferred provenance is equally valid — it just must be stated",
fm: map[string]string{
"half_life": "volatile", "applies": "local", "claim_provenance": "inferred",
},
},
{
// A value nobody can act on is worse than no value: a lint
// silently skips it, so the note looks classified and isn't.
name: "unknown half_life value",
fm: map[string]string{"half_life": "forever", "claim_provenance": "asserted"},
wantErr: `half_life: "forever" is not one of volatile, seasonal, durable`,
},
{
name: "unknown applies value",
fm: map[string]string{"applies": "galaxy", "claim_provenance": "asserted"},
wantErr: `applies: "galaxy" is not one of local, homelab, universal`,
},
{
// brain#21: a value a model inferred must never be
// indistinguishable from one Mathias asserted. Writing the field
// without saying who set it is exactly that laundering.
name: "envelope field without provenance",
fm: map[string]string{"half_life": "durable"},
wantErr: "claim_provenance is required when half_life or applies is set",
},
{
name: "applies without provenance",
fm: map[string]string{"applies": "homelab"},
wantErr: "claim_provenance is required when half_life or applies is set",
},
{
name: "unknown provenance value",
fm: map[string]string{"half_life": "durable", "claim_provenance": "probably"},
wantErr: `claim_provenance: "probably" is not one of asserted, inferred`,
},
{
// Provenance alone says who set fields that aren't there.
name: "provenance without any envelope field",
fm: map[string]string{"claim_provenance": "asserted"},
wantErr: "claim_provenance is set but neither half_life nor applies is",
},
{
// The glossary wing has used scope: homelab-core since #5. The
// envelope deliberately does NOT claim that key (brain#21).
name: "glossary scope is untouched",
fm: map[string]string{"scope": "homelab-core", "term": "koala"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := claim.ValidateEnvelope(tt.fm)
if tt.wantErr == "" {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
-2
View File
@@ -49,8 +49,6 @@ type rpcError struct {
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
type Server struct {
brainDir string
govAudit capture.AuditSink
govNotify govNotifier
pipeline pipeline.Config
llm pipeline.CompleteFunc
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
+8 -85
View File
@@ -6,11 +6,8 @@ import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
)
@@ -55,20 +52,6 @@ func noteToolDescriptors() []map[string]any {
}
}
// --- hyperguild#97: governance-rewrite observability -----------------------
// govNotifier raises an out-of-band alert. Declared here rather than importing
// internal/audit so the MCP package keeps depending on capture alone.
type govNotifier interface {
Notify(ctx context.Context, msg string) error
}
// WithGovernanceObserver attaches the audit sink and notifier that observe a
// successful brain_edit_root. Both are optional; either may be nil.
func (s *Server) WithGovernanceObserver(sink capture.AuditSink, n govNotifier) {
s.govAudit, s.govNotify = sink, n
}
type brainReadArgs struct {
Path string `json:"path"`
}
@@ -92,17 +75,8 @@ type brainEditArgs struct {
}
// brainEdit is the model-facing editor: notes only.
//
// Deliberately NOT audited (#97). A note edit is already fully reconstructible
// from git history, which carries the same before/after bytes plus a diff.
// Governance files get the extra trail because they change what later agents
// obey — the asymmetry is about instruction-carrying content, not about how
// hard the change is to reconstruct. Auditing every note edit would also bury
// the rare governance record under routine traffic, which is how an alert
// stops being read.
func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
_, out, err := s.editWithScope(args, notes.ScopeNotes)
return out, err
return s.editWithScope(args, notes.ScopeNotes)
}
// brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on
@@ -115,72 +89,22 @@ func (s *Server) brainEditRoot(ctx context.Context, args json.RawMessage) (json.
return nil, fmt.Errorf("brain_edit_root requires the local static principal; " +
"a governance file is not editable from a model-facing origin")
}
res, out, err := s.editWithScope(args, notes.ScopeRoot)
if err != nil {
// A refusal is not a rewrite. Recording or alerting on one would cry
// wolf, and the gate already logs its own denials.
return out, err
}
out, err := s.editWithScope(args, notes.ScopeRoot)
if err == nil {
// A durable instruction rewrite should be loud, not silent.
slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out))
s.observeGovernanceEdit(ctx, info.principal, res)
}
return out, err
}
// observeGovernanceEdit records and alerts on a rewrite that already landed.
//
// Best-effort by construction: the bytes are written and brain-sync will commit
// them within the quarter hour, so failing the call here would report a
// non-rewrite to the caller while the rewrite sits on disk. A failure to
// observe is therefore logged at ERROR — the one case where the trail itself is
// what broke, and the only remaining signal.
func (s *Server) observeGovernanceEdit(ctx context.Context, principal string, res notes.Result) {
// Path and hashes only. The notification leaves the cluster for a phone
// via a third-party topic; a hash identifies the rewrite, the bytes would
// leak whatever was written into an instruction file.
msg := fmt.Sprintf("brain_edit_root applied\npath=%s\nprincipal=%s\nbefore=%s\nafter=%s",
res.Path, principal, res.PrevContentHash, res.ContentHash)
if s.govNotify != nil {
if err := s.govNotify.Notify(ctx, msg); err != nil {
slog.Error("brain_edit_root notify failed", "path", res.Path, "err", err)
}
}
if s.govAudit == nil {
return
}
// Internal, not Confidential: a governance file is not client data, and
// Confidential would make DegradingSink REFUSE to record while loki is
// down rather than buffer — losing exactly the record that matters most.
outcome, err := s.govAudit.Reserve(ctx, classification.Internal)
if err != nil {
slog.Error("brain_edit_root audit reserve failed", "path", res.Path, "err", err)
return
}
entry := capture.AuditEntry{
Timestamp: time.Now().UTC(),
Principal: principal,
Actor: "brain_edit_root",
Harness: "mcp",
EffectiveClassification: classification.Internal.String(),
Items: []string{res.Path},
SecurityEvents: []string{fmt.Sprintf(
"governance file rewritten: %s before=%s after=%s",
res.Path, res.PrevContentHash, res.ContentHash)},
}
if err := s.govAudit.Record(ctx, entry, outcome); err != nil {
slog.Error("brain_edit_root audit record failed", "path", res.Path, "err", err)
}
}
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.Result, json.RawMessage, error) {
func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (json.RawMessage, error) {
var a brainEditArgs
if err := json.Unmarshal(args, &a); err != nil {
return notes.Result{}, nil, fmt.Errorf("parse args: %w", err)
return nil, fmt.Errorf("parse args: %w", err)
}
res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope)
if err != nil {
return notes.Result{}, nil, err
return nil, err
}
// Reindex only when the edit could have changed what the index reads.
@@ -197,8 +121,7 @@ func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (notes.R
res.ReindexError = ierr.Error()
}
}
out, merr := json.Marshal(res)
return res, out, merr
return json.Marshal(res)
}
// wingOf returns the wing for a wiki path, or "" for knowledge/ and root.
-170
View File
@@ -2,22 +2,13 @@
package mcp_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -134,164 +125,3 @@ func mustJSON(t *testing.T, v any) []byte {
require.NoError(t, err)
return b
}
// ---- hyperguild#97: a governance rewrite must be observed, not just gated ----
// govSink captures audit records. Governance edits are rare, so the whole
// history is kept and asserted on rather than sampled.
type govSink struct {
entries []capture.AuditEntry
outcomes []capture.AuditOutcome
recErr error
}
func (s *govSink) Reserve(context.Context, classification.Level) (capture.AuditOutcome, error) {
return capture.AuditCentral, nil
}
func (s *govSink) Record(_ context.Context, e capture.AuditEntry, o capture.AuditOutcome) error {
s.entries = append(s.entries, e)
s.outcomes = append(s.outcomes, o)
return s.recErr
}
type govNotifier struct {
msgs []string
err error
}
func (n *govNotifier) Notify(_ context.Context, msg string) error {
n.msgs = append(n.msgs, msg)
return n.err
}
// govServer wires capture (which is what re-derives the static principal) plus
// the #97 observer, and returns the brain dir so the test can read the file.
func govServer(t *testing.T) (*mcp.Server, string, *govSink, *govNotifier) {
t.Helper()
dir := newBrainRepo(t)
cfg, err := classification.Load(dir)
require.NoError(t, err)
svc := capture.NewService(brainstore.New(dir), capFakeTracker{}, cfg, audit.NewSlogSink(nil))
srv := mcp.NewServer(dir, nil, nil, nil)
srv.WithCapture(svc, nil, capStaticTok, "local-cli", capturehttp.NewOriginResolver(nil))
sink, notifier := &govSink{}, &govNotifier{}
srv.WithGovernanceObserver(sink, notifier)
return srv, dir, sink, notifier
}
// authCall is call() with an Authorization header, so the static principal is
// present and brain_edit_root's gate opens.
func authCall(t *testing.T, srv *mcp.Server, authz, name string, args map[string]any) map[string]any {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": name, "arguments": args},
}))
if authz != "" {
req.Header.Set("Authorization", authz)
}
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
return resp
}
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func TestBrainEditRoot_RecordsAnAuditEntryNamingPrincipalPathAndBothHashes(t *testing.T) {
// The gate proves a rewrite was ALLOWED. Only a record proves one HAPPENED
// — and brain-sync will commit it within 15 minutes authored `brain-sync`,
// so git history cannot tell it apart from routine note traffic.
srv, dir, sink, _ := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
})
require.NotContains(t, resp, "error", "%v", resp["error"])
require.Len(t, sink.entries, 1, "exactly one audit record per governance rewrite")
e := sink.entries[0]
assert.Equal(t, "local-cli", e.Principal)
assert.Contains(t, e.Items, "CLAUDE.md", "the record must name what was rewritten")
joined := strings.Join(e.SecurityEvents, " ")
assert.Contains(t, joined, sha256hex("# rules\n"), "before-hash must be recorded")
assert.Contains(t, joined, sha256hex("# rewritten\n"), "after-hash must be recorded")
assert.NotZero(t, e.Timestamp)
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
require.NoError(t, err)
assert.Equal(t, "# rewritten\n", string(got))
}
func TestBrainEditRoot_NotifiesWithPathAndHashesButNeverContent(t *testing.T) {
srv, _, _, notifier := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
})
require.NotContains(t, resp, "error", "%v", resp["error"])
require.Len(t, notifier.msgs, 1)
msg := notifier.msgs[0]
assert.Contains(t, msg, "CLAUDE.md")
assert.Contains(t, msg, "local-cli")
assert.Contains(t, msg, sha256hex("# rules\n"))
assert.Contains(t, msg, sha256hex("# rewritten\n"))
// The notification travels to a phone through a third-party topic. A hash
// identifies a rewrite; the bytes would leak whatever was written.
assert.NotContains(t, msg, "# rewritten", "no file content in the notification body")
assert.NotContains(t, msg, "# rules")
}
func TestBrainEditRoot_RefusedProducesNoRecordNoNotificationNoWrite(t *testing.T) {
srv, dir, sink, notifier := govServer(t)
// No Authorization header → no static principal → the gate stays shut.
resp := authCall(t, srv, "", "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "# rules", "new_str": "# rewritten",
})
require.Contains(t, resp, "error")
assert.Empty(t, sink.entries, "a refusal is not a rewrite — recording one would cry wolf")
assert.Empty(t, notifier.msgs)
got, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
require.NoError(t, err)
assert.Equal(t, "# rules\n", string(got))
}
func TestBrainEditRoot_FailedEditProducesNoRecordNoNotification(t *testing.T) {
// Authorised but no match: the door opened and nothing went through it.
srv, _, sink, notifier := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit_root", map[string]any{
"path": "CLAUDE.md", "old_str": "absent-from-the-file", "new_str": "x",
})
require.Contains(t, resp, "error")
assert.Empty(t, sink.entries)
assert.Empty(t, notifier.msgs)
}
func TestBrainEdit_OnOrdinaryNotesIsNotAudited(t *testing.T) {
// The decided asymmetry (#97): note edits are covered by git history, which
// carries the same before/after bytes with a diff. Governance files get the
// extra trail because they change what later agents obey, not because they
// are harder to reconstruct.
srv, _, sink, notifier := govServer(t)
resp := authCall(t, srv, "Bearer "+capStaticTok, "brain_edit", map[string]any{
"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA",
})
require.NotContains(t, resp, "error", "%v", resp["error"])
assert.Empty(t, sink.entries, "note edits must not fill the governance audit trail")
assert.Empty(t, notifier.msgs)
}
-6
View File
@@ -37,10 +37,6 @@ var pathLocks sync.Map // string -> *sync.Mutex
// Result is what a successful edit reports.
type Result struct {
Path string `json:"path"`
// PrevContentHash is the sha256 of the file as it was before this edit.
// Paired with ContentHash it identifies a rewrite in an audit trail or an
// alert without carrying any of the bytes (hyperguild#97).
PrevContentHash string `json:"prev_content_hash"`
ContentHash string `json:"content_hash"`
BytesBefore int `json:"bytes_before"`
BytesAfter int `json:"bytes_after"`
@@ -153,10 +149,8 @@ func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
}
sum := sha256.Sum256([]byte(after))
prev := sha256.Sum256(before)
return Result{
Path: t.Rel,
PrevContentHash: hex.EncodeToString(prev[:]),
ContentHash: hex.EncodeToString(sum[:]),
BytesBefore: len(before),
BytesAfter: len(after),
+1 -35
View File
@@ -96,11 +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, err := reviseOrCreate(dest, page)
if err != nil {
return Result{}, err
}
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)
}
}
@@ -119,36 +115,6 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
return Result{Pages: written, Warnings: warnings}, nil
}
// 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, error) {
existing, err := os.ReadFile(dest)
switch {
case os.IsNotExist(err):
return page.Content, nil
case err != nil:
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
}
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
}
// mergeAll deduplicates pages by path, merging content from later occurrences.
func mergeAll(pages []wiki.Page) []wiki.Page {
order := make([]string, 0, len(pages))
@@ -171,65 +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")
}
+13 -104
View File
@@ -29,16 +29,12 @@ type section struct {
}
// Merge combines two Page values with the same path.
// Frontmatter is taken from a. The preamble (content before the first ##
// heading) is taken from a — same "on-disk wins" default every unlisted
// named section gets, kept as a separate field rather than a section with
// an empty heading so it can never collide with one (see parseSections).
// 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.
// 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.
func Merge(a, b Page) Page {
fmA, preambleA, secsA := parseSections(a.Content)
_, _, secsB := parseSections(b.Content)
fmA, secsA := parseSections(a.Content)
_, secsB := parseSections(b.Content)
idx := make(map[string]int, len(secsA))
for i, s := range secsA {
@@ -57,24 +53,14 @@ func Merge(a, b Page) Page {
case bulletSections[sB.heading]:
secsA[i].content = mergeBullets(sA.content, sB.content)
case appendSections[sB.heading]:
secsA[i].content = appendNew(sA.content, sB.content)
secsA[i].content = strings.TrimRight(sA.content, "\n") + "\n\n" + strings.TrimLeft(sB.content, "\n")
}
}
return Page{Path: a.Path, Content: rebuildContent(fmA, preambleA, secsA)}
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)}
}
// parseSections splits markdown into its frontmatter, its preamble (content
// before the first `## ` heading), and its `## `-headed sections.
//
// The preamble is returned separately rather than as a section with an
// empty heading — a section keyed by heading=="" would collide with a
// literal degenerate `## ` (marker, no title text; strings.TrimPrefix also
// yields ""), which is a distinct, addressable section a page can genuinely
// have, not the same thing as "no heading yet." Two entries sharing one key
// in Merge's idx map means one of them silently loses lookup visibility —
// found in review as real, reproducible data loss, not a hypothetical.
func parseSections(markdown string) (frontmatter, preamble string, sections []section) {
func parseSections(markdown string) (frontmatter string, sections []section) {
lines := strings.Split(markdown, "\n")
i := 0
@@ -92,22 +78,6 @@ func parseSections(markdown string) (frontmatter, preamble string, sections []se
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
}
// Everything before the first `## ` heading — an intro paragraph, or, if
// the page has no headings at all, the entire body — is ordinary content,
// not something this parser is entitled to drop (hyperguild#103 review:
// dropping it meant Merge, one caller up, would silently replace the
// whole on-disk body with the fresh extraction whenever a page didn't
// follow the ## convention — exactly the class of bug this file exists
// to prevent).
var preambleLines []string
for ; i < len(lines); i++ {
if strings.HasPrefix(lines[i], "## ") {
break
}
preambleLines = append(preambleLines, lines[i])
}
preamble = strings.Join(preambleLines, "\n")
var cur *section
for ; i < len(lines); i++ {
line := lines[i]
@@ -126,85 +96,24 @@ func parseSections(markdown string) (frontmatter, preamble string, sections []se
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, preamble string, sections []section) string {
func rebuildContent(frontmatter string, sections []section) string {
var sb strings.Builder
sb.WriteString(frontmatter)
if body := strings.Trim(preamble, "\n"); body != "" {
fmt.Fprintf(&sb, "\n%s\n", body)
}
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()
}
// canonicalLine collapses a line to its comparison key: internal whitespace
// runs (a re-extraction's bullet spacing, tabs vs spaces, trailing spaces)
// must not make the same logical line look new. strings.Fields already
// trims and splits on any whitespace run, so joining it back with single
// spaces is exactly "same words, same order, whitespace-insensitive."
func canonicalLine(line string) string {
return strings.Join(strings.Fields(line), " ")
}
// 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 c := canonicalLine(line); c != "" {
seen[c] = true
}
}
var add []string
for _, line := range strings.Split(b, "\n") {
c := canonicalLine(line)
if c == "" || seen[c] {
continue
}
seen[c] = 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
for _, line := range strings.Split(a+b, "\n") {
c := canonicalLine(line)
if c == "" || seen[c] {
trimmed := strings.TrimSpace(line)
if trimmed == "" || seen[trimmed] {
continue
}
seen[c] = true
seen[trimmed] = true
lines = append(lines, line)
}
return strings.Join(lines, "\n") + "\n"
-121
View File
@@ -53,124 +53,3 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
assert.Contains(t, got.Content, "title: A")
assert.NotContains(t, got.Content, "title: B")
}
// --- hyperguild#103 review: a page with content outside any `## ` section ---
// must never lose that content on merge. parseSections used to only capture
// text between `## ` headings — anything before the first heading (a common,
// ordinary shape for a hand-written note: an intro paragraph before the first
// section) or, worse, an entire page with no `## ` headings at all (a legacy
// note, a stub, any hand-edit that doesn't follow the section convention)
// vanished silently. Merge would then fill the page entirely from the fresh
// extraction — the opposite of "never silently rewrite what a human put
// there," the guarantee this whole file exists to provide.
func TestMerge_PreservesOnDiskContentWithNoSectionHeadings(t *testing.T) {
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nJust some free text a human wrote. No section headings at all.\n"}
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nFresh extraction's take.\n"}
got := Merge(a, b)
assert.Contains(t, got.Content, "Just some free text a human wrote",
"on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction")
}
func TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble(t *testing.T) {
// Second review pass on #103: representing the preamble as a section with
// heading=="" collides with a literal `## ` (marker, no title text) —
// strings.TrimPrefix("## ", "## ") is also "". `a` has only a preamble
// (no degenerate heading of its own); `b` introduces a genuinely new
// degenerate `## ` section. Because both key to "", Merge's idx thinks
// b's section "already exists" in a (it's really just a's unrelated
// preamble) and never adds it — b's section vanishes from the output
// entirely, not merged, not kept, not appended.
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph.\n\n## Definition\n\nA thing.\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## \n\nA genuinely new degenerate section's body.\n\n## Definition\n\nA thing.\n"}
got := Merge(a, b)
assert.Contains(t, got.Content, "An intro paragraph.", "a's own preamble must survive")
assert.Contains(t, got.Content, "A genuinely new degenerate section's body.",
"b's degenerate section is new content, not a collision with a's unrelated preamble — it must not be silently dropped")
}
func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) {
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"}
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"}
got := Merge(a, b)
assert.Contains(t, got.Content, "An intro paragraph before the first heading",
"text before the first ## heading is ordinary page content, not something the parser is entitled to drop")
}
// --- hyperguild#103 review: dedup must be whitespace-insensitive -----------
// Page content is produced by an LLM per run, so byte-identical re-extraction
// of "the same" logical line is not guaranteed — different bullet spacing or
// trailing whitespace on an otherwise-identical line must still dedup, or
// idempotency only holds when the extractor happens to reproduce whitespace
// exactly, which is not a real guarantee.
func TestMerge_AppendSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge?\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge? \n"}
got := Merge(a, b)
assert.Equal(t, 1, strings.Count(got.Content, "Does this converge?"),
"same logical line with different whitespace must dedup, not duplicate")
}
func TestMerge_BulletSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
got := Merge(a, b)
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"),
"same logical bullet with different whitespace must dedup, not duplicate")
}
// --- 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")
}