The keystone gap from the brain intent↔interface analysis (#43, evidence in #44). update/supersede is the single highest-value missing verb: 5/5 agent mismatch, flagged top-priority in both columns. The brain today is append + keyword-search — there is no verb to revise an existing note. Agents that need to revise blind-re-write the same slug via a second brain_write (3 of 5 observed re-writes happened within 30s of each other), producing duplicates and silent contradictions with no way to tell whether a re-write deduped or forked.
This issue tracks the agent-facing write verb decided in the 2026-06-22 review session. Shape and provenance model are settled (see Decisions below); signatures here are the implementation target.
This blocks the session-retro skill — that skill emits findings that must revise prior notes, and without a supersede verb it reproduces exactly the duplicate-and-contradict failure documented in #44.
Decisions taken (2026-06-22)
Shape: supersede-by-slug, whole-note replace (not partial-patch, not append-tombstone). Rationale: the evidence shows agents regenerate whole notes; a patch/diff addressing scheme solves a problem nobody has. (#44 cluster 1.)
Provenance: git is the history mechanism. No .history/ sidecar, no in-tree tombstone. The prior version is recoverable from git; superseded content is not kept queryable. Frontmatter stamps the supersession for human/agent legibility.
Read-after-write folded in.brain_update (and brain_write) return the landed id + content hash, giving agents read-after-write confirmation for free on the write path. A thin brain_get(id) covers the create-path / get-by-id half of the verify_write_landed gap (#44 cluster 2, 4/4 mismatch) without its own issue.
Out of scope here: semantic read (#44 cluster 3), brain_answer trust (#44 cluster 4), the human promote half (#38), consumer-split redesign (deferred per #43).
Primary tool: brain_update
Supersede an existing note in place: replace body, rewrite frontmatter, re-sync embedding.
tool: brain_update
params:
slug: string // target note slug (within wing/hall) OR full path; identifies the note to supersede
wing: string // required if slug is not a full path
hall: string // required if slug is not a full path
content: string // new full body (whole-note replace)
reason: string? // optional short note on why superseded — stamped into frontmatter
returns:
{ id, path, content_hash, superseded: bool }
Behaviour:
Resolve the target note from wing/hall/slug (or full path). If no such note exists → error (do not silently create; that's brain_write's job). Clear error so the agent can fall back to create.
Replace the body with content.
Rewrite frontmatter: set updated_at (now), supersedes (prior content_hash), and supersede_reason if reason given. Preserve wing, hall, created_at, and any custom fields.
Re-sync the embedding for the note (pgvector) so retrieval reflects the new content. NB: distinct from #23 (closed) — that was the staleness fix; this is the write verb that must invoke re-sync as part of its contract.
Commit (git is the history layer — the prior version is recoverable from the commit before this one).
Return id, path, content_hash of the landed note, superseded: true.
Secondary: read-after-write on the create path
brain_write return contract extended to include { id, path, content_hash } (currently does not return a stable handle — this is why agents lexically re-query their own fresh note).
New thin getter:
tool: brain_get
params:
id: string // OR
path: string
returns:
{ id, path, content_hash, frontmatter, body }
brain_get is read-only — LOW risk tier. brain_update is a content-replace — MEDIUM risk tier (reversible via git; the no-silent-create guard prevents accidental new-note creation).
Layout note
The promote-half issue (#38) and the older specs assume brain/raw/ → brain/wiki/. Per #43 the current curation-state split is knowledge/ (unreviewed) → wiki/ (promoted). brain_update operates on notes wherever they live (both knowledge/ and wiki/) — it is a revise-in-place verb, orthogonal to the curation-state move. Reconcile path conventions with #38 at implementation time.
Acceptance criteria
brain_update supersedes an existing note: body replaced, updated_at/supersedes frontmatter stamped, prior fields preserved
brain_update on a non-existent target → clear error, no note created (no silent create)
Embedding re-synced after update; a semantic/lexical query reflects the new content, not the old
brain_update returns { id, path, content_hash }
brain_write return contract extended to include { id, path, content_hash }
brain_get(id|path) returns the landed note (frontmatter + body) — read-after-write confirmation without lexical re-query
Prior version recoverable from git (no sidecar/tombstone — verify the commit-before holds the old body)
task check passes
Both tools registered in ingestion/internal/mcp/server.go
Tests: update happy path, update non-existent (error + no create), embedding-reflects-new-content, brain_get by id, brain_get by path, brain_write returns handle
Related
#43 — interface problem statement (this is the keystone gap it names)
#44 — agent-column evidence (closed); clusters 1 + 2 are what this issue addresses
#38 — promote half (brain_pending/brain_promote); complementary, not duplicated here
#23 (closed) — embedding re-sync on edit; this verb invokes re-sync but is the missing write verb, not the staleness fix
gitea-mcp #40 (tbd_ship) — same intent-vs-mechanism fix applied to the gitea repo
Blocks: session-retro skill (needs supersede + read-after-write to write findings without duplicating/contradicting prior notes)
## Context
The keystone gap from the brain intent↔interface analysis (#43, evidence in #44). `update/supersede` is the single highest-value missing verb: **5/5 agent mismatch**, flagged top-priority in both columns. The brain today is **append + keyword-search** — there is no verb to revise an existing note. Agents that need to revise blind-re-write the same slug via a second `brain_write` (3 of 5 observed re-writes happened within 30s of each other), producing duplicates and silent contradictions with no way to tell whether a re-write deduped or forked.
This issue tracks the **agent-facing write verb** decided in the 2026-06-22 review session. Shape and provenance model are settled (see Decisions below); signatures here are the implementation target.
This **blocks the session-retro skill** — that skill emits findings that must revise prior notes, and without a supersede verb it reproduces exactly the duplicate-and-contradict failure documented in #44.
## Decisions taken (2026-06-22)
- **Shape: supersede-by-slug, whole-note replace** (not partial-patch, not append-tombstone). Rationale: the evidence shows agents regenerate whole notes; a patch/diff addressing scheme solves a problem nobody has. (#44 cluster 1.)
- **Provenance: git is the history mechanism.** No `.history/` sidecar, no in-tree tombstone. The prior version is recoverable from git; superseded content is **not** kept queryable. Frontmatter stamps the supersession for human/agent legibility.
- **Read-after-write folded in.** `brain_update` (and `brain_write`) return the landed id + content hash, giving agents read-after-write confirmation for free on the write path. A thin `brain_get(id)` covers the create-path / get-by-id half of the `verify_write_landed` gap (#44 cluster 2, 4/4 mismatch) without its own issue.
Out of scope here: semantic read (#44 cluster 3), `brain_answer` trust (#44 cluster 4), the human promote half (#38), consumer-split redesign (deferred per #43).
## Primary tool: `brain_update`
Supersede an existing note in place: replace body, rewrite frontmatter, re-sync embedding.
```
tool: brain_update
params:
slug: string // target note slug (within wing/hall) OR full path; identifies the note to supersede
wing: string // required if slug is not a full path
hall: string // required if slug is not a full path
content: string // new full body (whole-note replace)
reason: string? // optional short note on why superseded — stamped into frontmatter
returns:
{ id, path, content_hash, superseded: bool }
```
Behaviour:
1. Resolve the target note from `wing`/`hall`/`slug` (or full path). If no such note exists → error (do **not** silently create; that's `brain_write`'s job). Clear error so the agent can fall back to create.
2. Replace the body with `content`.
3. Rewrite frontmatter: set `updated_at` (now), `supersedes` (prior `content_hash`), and `supersede_reason` if `reason` given. Preserve `wing`, `hall`, `created_at`, and any custom fields.
4. Re-sync the embedding for the note (pgvector) so retrieval reflects the new content. NB: distinct from #23 (closed) — that was the staleness *fix*; this is the *write verb* that must invoke re-sync as part of its contract.
5. Commit (git is the history layer — the prior version is recoverable from the commit before this one).
6. Return `id`, `path`, `content_hash` of the landed note, `superseded: true`.
## Secondary: read-after-write on the create path
- `brain_write` return contract extended to include `{ id, path, content_hash }` (currently does not return a stable handle — this is why agents lexically re-query their own fresh note).
- New thin getter:
```
tool: brain_get
params:
id: string // OR
path: string
returns:
{ id, path, content_hash, frontmatter, body }
```
`brain_get` is read-only — LOW risk tier. `brain_update` is a content-replace — MEDIUM risk tier (reversible via git; the no-silent-create guard prevents accidental new-note creation).
## Layout note
The promote-half issue (#38) and the older specs assume `brain/raw/` → `brain/wiki/`. Per #43 the current curation-state split is `knowledge/` (unreviewed) → `wiki/` (promoted). `brain_update` operates on notes wherever they live (both `knowledge/` and `wiki/`) — it is a revise-in-place verb, orthogonal to the curation-state move. Reconcile path conventions with #38 at implementation time.
## Acceptance criteria
- [ ] `brain_update` supersedes an existing note: body replaced, `updated_at`/`supersedes` frontmatter stamped, prior fields preserved
- [ ] `brain_update` on a non-existent target → clear error, no note created (no silent create)
- [ ] Embedding re-synced after update; a semantic/lexical query reflects the new content, not the old
- [ ] `brain_update` returns `{ id, path, content_hash }`
- [ ] `brain_write` return contract extended to include `{ id, path, content_hash }`
- [ ] `brain_get(id|path)` returns the landed note (frontmatter + body) — read-after-write confirmation without lexical re-query
- [ ] Prior version recoverable from git (no sidecar/tombstone — verify the commit-before holds the old body)
- [ ] `task check` passes
- [ ] Both tools registered in `ingestion/internal/mcp/server.go`
- [ ] Tests: update happy path, update non-existent (error + no create), embedding-reflects-new-content, brain_get by id, brain_get by path, brain_write returns handle
## Related
- #43 — interface problem statement (this is the keystone gap it names)
- #44 — agent-column evidence (closed); clusters 1 + 2 are what this issue addresses
- #38 — promote half (`brain_pending`/`brain_promote`); complementary, not duplicated here
- #23 (closed) — embedding re-sync on edit; this verb *invokes* re-sync but is the missing write verb, not the staleness fix
- gitea-mcp #40 (`tbd_ship`) — same intent-vs-mechanism fix applied to the gitea repo
- **Blocks:** session-retro skill (needs supersede + read-after-write to write findings without duplicating/contradicting prior notes)
Implement this issue in mathias/hyperguild. The decisions in the body above are settled — don't relitigate the shape. Verify everything against the brain MCP code as you go. This unblocks the session-retro skill, so correctness of the supersede + re-index path is the whole point — a half-working brain_update that doesn't refresh retrieval is worse than none.
handlers.go — tools() descriptor list, plus the brainWrite/brainQuery/brainTunnel handlers to mirror. Note brainWrite calls api.WriteNote(...) and currently returns only {"path": relPath}.
handlers_test.go, integration_test.go — test patterns to follow.
The note-writing primitive is api.WriteNote in ingestion/internal/api/. Find its read/resolve counterpart (or add one) for the supersede path.
A tool is registered in three places — all three must be updated for any new tool: (1) the tools() descriptor slice, (2) the handleCall switch, (3) the package doc comment at the top of server.go.
Step 0 — the load-bearing investigation, do this FIRST
The supersede contract requires retrieval to reflect the new content after an update. Two indexes are downstream of a write:
Graph — handled explicitly in-handler via s.indexInGraph(ctx, op, relPath). Easy: brain_update calls it the same way brain_write does.
pgvector embeddings — brain_query reads s.vector + s.embedder live (search.QueryContext), but there is no visible embedding re-index call inside the write handler. Before writing any code, trace how embeddings get (re)built for a note: is it a watcher, a separate sync job, an on-read embed, or something api.WriteNote triggers indirectly?
Report what you find before implementing the re-sync. If embeddings are rebuilt out-of-band (e.g. a periodic sync), then "re-sync on update" may mean "ensure the out-of-band job re-embeds the changed file" rather than an in-handler call — and the acceptance criterion "a query reflects the new content, not the old" needs to be satisfied against whatever that real mechanism is. Do not invent an embedding call that doesn't match the existing architecture. If embeddings are only ever built by a separate process and brain_update genuinely can't trigger a refresh, stop and flag it in a PR comment — that's a design question for Mathias, not something to paper over.
Args: slug (or full path), wing+hall (required if slug isn't a full path), content (new full body), reason (optional).
Resolve the target note. If it doesn't exist → error, do NOT create (that's brain_write's job). Error must be clear enough that a caller knows to fall back to brain_write.
Replace body with content. Rewrite frontmatter: set updated_at (now, UTC), supersedes (prior content hash), supersede_reason if given. Preservewing, hall, created_at, and any custom frontmatter fields.
Re-sync embedding (per Step 0 findings) and re-index the graph (s.indexInGraph). Rebuild the wing _index.md if it lands in the structured wiki (mirror brainWrite's BuildWingIndex call). Auto-tunnel optional on update — match brain_write unless it causes duplicate links.
Risk tier: MEDIUM (content replace; reversible via git).
2. brain_get — fetch by id or path (NEW, read-only)
Args: id OR path (one required).
Returns { id, path, content_hash, frontmatter, body }.
This is the create-path read-after-write primitive — there is no fetch-by-handle today, only search. Risk tier: LOW.
3. Extend brain_write return contract
Currently returns {"path": relPath}. Add id and content_hash so the create path also gives a stable handle (this is why agents currently lexically re-query their own fresh notes).
Keep path for backward compat; add fields, don't rename.
Decide and state: what is id, what is content_hash?
There's no stable note id in the current surface (paths are the de-facto handle). Pick the simplest correct thing — likely id = the relative path (or a slug-derived stable key) and content_hash = sha256 of the post-write body. Whatever you choose, brain_get(id) and brain_get(path) must both resolve, and the id returned by write/update must round-trip through brain_get. State your choice in the PR description.
Conventions
Trunk-based, one file per commit, conventional-commit messages. Branch from main.
task check is the gate — must pass before the PR is mergeable.
update non-existent target → error AND no note created
retrieval reflects new content not old after update (proves the re-sync works — make it real, not a stub)
brain_get by id
brain_get by path
brain_write returns {id, path, content_hash}
Update the package doc comment, tools(), and handleCall for both new tools.
Layout note
Per #43 the curation-state split is knowledge/ (unreviewed) → wiki/ (promoted). brain_update operates on notes wherever they live — revise-in-place, orthogonal to the curation-state move (#38's brain_promote is the move verb, separate issue). Don't conflate them.
Open the PR, don't auto-merge
Open a PR against main referencing #45 and post your Step 0 embedding findings as a PR comment. Mathias reviews and merges from claude.ai. If Step 0 surfaces that the re-sync can't be done in-handler, raise it in the PR before building the rest — the one thing worth blocking on.
Report back
Step 0 findings (how embeddings re-index, and how brain_update triggers it).
Your id / content_hash choice.
PR URL + task check result.
Any acceptance criterion you couldn't satisfy and why.
## Dispatch — koala-Claude implementation handoff
Implement this issue in `mathias/hyperguild`. The decisions in the body above are settled — don't relitigate the shape. Verify everything against the brain MCP code as you go. This unblocks the session-retro skill, so correctness of the supersede + re-index path is the whole point — a half-working `brain_update` that doesn't refresh retrieval is worse than none.
### Where the code lives
All in `ingestion/internal/mcp/`:
- `server.go` — package doc comment (lists tools), `handleCall` dispatch switch, server wiring (`WithHybridRetrieval` = embedder+pgvector, `WithGraph` = entity/edge re-index).
- `handlers.go` — `tools()` descriptor list, plus the `brainWrite`/`brainQuery`/`brainTunnel` handlers to mirror. Note `brainWrite` calls `api.WriteNote(...)` and currently returns only `{"path": relPath}`.
- `handlers_test.go`, `integration_test.go` — test patterns to follow.
- The note-writing primitive is `api.WriteNote` in `ingestion/internal/api/`. Find its read/resolve counterpart (or add one) for the supersede path.
A tool is registered in **three** places — all three must be updated for any new tool: (1) the `tools()` descriptor slice, (2) the `handleCall` switch, (3) the package doc comment at the top of `server.go`.
### Step 0 — the load-bearing investigation, do this FIRST
The supersede contract requires retrieval to reflect the new content after an update. Two indexes are downstream of a write:
1. **Graph** — handled explicitly in-handler via `s.indexInGraph(ctx, op, relPath)`. Easy: `brain_update` calls it the same way `brain_write` does.
2. **pgvector embeddings** — `brain_query` reads `s.vector` + `s.embedder` live (`search.QueryContext`), but there is **no visible embedding re-index call inside the write handler**. Before writing any code, trace how embeddings get (re)built for a note: is it a watcher, a separate sync job, an on-read embed, or something `api.WriteNote` triggers indirectly?
**Report what you find before implementing the re-sync.** If embeddings are rebuilt out-of-band (e.g. a periodic sync), then "re-sync on update" may mean "ensure the out-of-band job re-embeds the changed file" rather than an in-handler call — and the acceptance criterion "a query reflects the new content, not the old" needs to be satisfied against whatever that real mechanism is. Do not invent an embedding call that doesn't match the existing architecture. If embeddings are only ever built by a separate process and `brain_update` genuinely can't trigger a refresh, **stop and flag it in a PR comment** — that's a design question for Mathias, not something to paper over.
### What to build
**1. `brain_update` — supersede-by-slug, whole-note replace**
- Args: `slug` (or full path), `wing`+`hall` (required if slug isn't a full path), `content` (new full body), `reason` (optional).
- Resolve the target note. **If it doesn't exist → error, do NOT create** (that's `brain_write`'s job). Error must be clear enough that a caller knows to fall back to `brain_write`.
- Replace body with `content`. Rewrite frontmatter: set `updated_at` (now, UTC), `supersedes` (prior content hash), `supersede_reason` if given. **Preserve** `wing`, `hall`, `created_at`, and any custom frontmatter fields.
- Re-sync embedding (per Step 0 findings) and re-index the graph (`s.indexInGraph`). Rebuild the wing `_index.md` if it lands in the structured wiki (mirror `brainWrite`'s `BuildWingIndex` call). Auto-tunnel optional on update — match `brain_write` unless it causes duplicate links.
- Return `{ id, path, content_hash, superseded: true }`.
- Risk tier: MEDIUM (content replace; reversible via git).
**2. `brain_get` — fetch by id or path (NEW, read-only)**
- Args: `id` OR `path` (one required).
- Returns `{ id, path, content_hash, frontmatter, body }`.
- This is the create-path read-after-write primitive — there is no fetch-by-handle today, only search. Risk tier: LOW.
**3. Extend `brain_write` return contract**
- Currently returns `{"path": relPath}`. Add `id` and `content_hash` so the create path also gives a stable handle (this is why agents currently lexically re-query their own fresh notes).
- Keep `path` for backward compat; add fields, don't rename.
**Decide and state: what is `id`, what is `content_hash`?**
There's no stable note id in the current surface (paths are the de-facto handle). Pick the simplest correct thing — likely `id` = the relative path (or a slug-derived stable key) and `content_hash` = sha256 of the post-write body. Whatever you choose, **`brain_get(id)` and `brain_get(path)` must both resolve**, and the `id` returned by write/update must round-trip through `brain_get`. State your choice in the PR description.
### Conventions
- **Trunk-based, one file per commit**, conventional-commit messages. Branch from `main`.
- `task check` is the gate — must pass before the PR is mergeable.
- Tests required (mirror `handlers_test.go`):
- update happy path (body replaced, frontmatter stamped, prior fields preserved)
- update non-existent target → error AND no note created
- **retrieval reflects new content not old** after update (proves the re-sync works — make it real, not a stub)
- `brain_get` by id
- `brain_get` by path
- `brain_write` returns `{id, path, content_hash}`
- Update the package doc comment, `tools()`, and `handleCall` for both new tools.
### Layout note
Per #43 the curation-state split is `knowledge/` (unreviewed) → `wiki/` (promoted). `brain_update` operates on notes **wherever they live** — revise-in-place, orthogonal to the curation-state move (#38's `brain_promote` is the move verb, separate issue). Don't conflate them.
### Open the PR, don't auto-merge
Open a PR against `main` referencing #45 and post your Step 0 embedding findings as a PR comment. Mathias reviews and merges from claude.ai. If Step 0 surfaces that the re-sync can't be done in-handler, raise it in the PR **before** building the rest — the one thing worth blocking on.
### Report back
- Step 0 findings (how embeddings re-index, and how `brain_update` triggers it).
- Your `id` / `content_hash` choice.
- PR URL + `task check` result.
- Any acceptance criterion you couldn't satisfy and why.
No MCP write handler re-embeds in-handler — not even brain_write. The Server holds only the read side (s.vector, s.embedder); it has no vectorstore.Store (Upsert/Delete), so it cannot embed in-handler. Embeddings refresh strictly out-of-band:
vectorstore.StartSync — background ticker (cmd/server/main.go:301, default 5 min), mtime-driven: re-embeds any file whose mtime exceeds its oldest chunk's updated_at, deleting stale chunks first.
POST /backfill-embeddings — manual full sync.
So "re-sync on update" = ensure the out-of-band Sync re-embeds the changed file. brain_update rewrites the note via os.WriteFile → mtime advances → next Sync pass re-embeds the new body + drops the stale chunk. Exactly how brain_write behaves. I did not add a divergent in-handler embed call. Verified against the real mechanism in TestBrainUpdateReembedsNewContent (no stub of the re-index path).
Design note: semantic/vector retrieval has a ≤5 min staleness window post-supersede; BM25 + brain_get are immediate. Deferred as #47 (wire a vectorstore.Store into the MCP server only if a consumer needs sub-5-min vector read-after-write).
id / content_hash choice
id = brain-relative slash path (the de-facto handle). brain_get(id) and brain_get(path) resolve identically; the id from write/update round-trips through brain_get.
content_hash = lowercase hex sha256 of the full file bytes on disk. brain_get recomputes → identical; brain_update hashes the prior file and stamps it as supersedes.
Acceptance criteria — all met
brain_update supersedes: body replaced, updated_at/supersedes stamped, prior fields (created_at/wing/hall/custom) preserved
Missing target → clear error, no silent create
Embedding re-synced after update (mtime-driven Sync); query reflects new content — proven by a real Sync test
Both tools registered in all three sites: tools(), handleCall, package doc
Tests: update happy / missing-no-create / full-path, get by id, get by path, write handle, embedding-reflects-new-content, frontmatter editor, traversal guards
chore(context): re-sync derived adapters — task check context gate flagged pre-existing root-AGENT.md drift; committing the regen is the documented remedy, not a content change here.
No tag/SemVer bump — happens on merge to main from claude.ai, not on the branch.
Follow-up filed
#47 — optional synchronous in-handler embedding refresh (the ≤5 min vector-RAW window).
Brain
wiki/hyperguild/facts/brain-mcp-embeddings-out-of-band-sync.md — the "write handlers don't re-embed; Sync is out-of-band + mtime-driven" footgun, so the next person adding a write verb doesn't re-derive it.
Unmet criteria: none.
## Implemented — PR #46 (open, not merged per dispatch)
**PR:** https://git.d-ma.be/mathias/hyperguild/pulls/46
### Step 0 — embedding re-index (load-bearing finding)
No MCP write handler re-embeds in-handler — **not even `brain_write`**. The `Server` holds only the read side (`s.vector`, `s.embedder`); it has no `vectorstore.Store` (`Upsert`/`Delete`), so it cannot embed in-handler. Embeddings refresh strictly out-of-band:
1. `vectorstore.StartSync` — background ticker (`cmd/server/main.go:301`, default 5 min), **mtime-driven**: re-embeds any file whose mtime exceeds its oldest chunk's `updated_at`, deleting stale chunks first.
2. `POST /backfill-embeddings` — manual full sync.
So "re-sync on update" = ensure the out-of-band Sync re-embeds the changed file. `brain_update` rewrites the note via `os.WriteFile` → mtime advances → next Sync pass re-embeds the new body + drops the stale chunk. Exactly how `brain_write` behaves. I did **not** add a divergent in-handler embed call. Verified against the real mechanism in `TestBrainUpdateReembedsNewContent` (no stub of the re-index path).
**Design note:** semantic/vector retrieval has a ≤5 min staleness window post-supersede; BM25 + `brain_get` are immediate. Deferred as #47 (wire a `vectorstore.Store` into the MCP server only if a consumer needs sub-5-min vector read-after-write).
### `id` / `content_hash` choice
- `id` = brain-relative slash path (the de-facto handle). `brain_get(id)` and `brain_get(path)` resolve identically; the id from write/update round-trips through `brain_get`.
- `content_hash` = lowercase hex sha256 of the full file bytes on disk. `brain_get` recomputes → identical; `brain_update` hashes the prior file and stamps it as `supersedes`.
### Acceptance criteria — all met
- [x] `brain_update` supersedes: body replaced, `updated_at`/`supersedes` stamped, prior fields (`created_at`/`wing`/`hall`/custom) preserved
- [x] Missing target → clear error, **no silent create**
- [x] Embedding re-synced after update (mtime-driven Sync); query reflects new content — proven by a real Sync test
- [x] `brain_update` returns `{id, path, content_hash}` (+ `superseded`)
- [x] `brain_write` extended `{path}` → `{id, path, content_hash}` (path kept for compat)
- [x] `brain_get(id|path)` returns frontmatter + body
- [x] Prior version recoverable from git (no sidecar/tombstone)
- [x] `task check` passes (exit 0; lint 0 issues, vet clean, `-race` tests green)
- [x] Both tools registered in all three sites: `tools()`, `handleCall`, package doc
- [x] Tests: update happy / missing-no-create / full-path, get by id, get by path, write handle, embedding-reflects-new-content, frontmatter editor, traversal guards
### Commits (trunk-style, one logical change each)
- `feat(brain): add UpdateNote/ReadNote supersede primitives + frontmatter editor`
- `feat(mcp): register brain_update + brain_get, extend brain_write handle`
- `chore(context): re-sync derived adapters` — `task check` context gate flagged pre-existing root-`AGENT.md` drift; committing the regen is the documented remedy, not a content change here.
No tag/SemVer bump — happens on merge to main from claude.ai, not on the branch.
### Follow-up filed
- #47 — optional synchronous in-handler embedding refresh (the ≤5 min vector-RAW window).
### Brain
- `wiki/hyperguild/facts/brain-mcp-embeddings-out-of-band-sync.md` — the "write handlers don't re-embed; Sync is out-of-band + mtime-driven" footgun, so the next person adding a write verb doesn't re-derive it.
Unmet criteria: **none.**
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Context
The keystone gap from the brain intent↔interface analysis (#43, evidence in #44).
update/supersedeis the single highest-value missing verb: 5/5 agent mismatch, flagged top-priority in both columns. The brain today is append + keyword-search — there is no verb to revise an existing note. Agents that need to revise blind-re-write the same slug via a secondbrain_write(3 of 5 observed re-writes happened within 30s of each other), producing duplicates and silent contradictions with no way to tell whether a re-write deduped or forked.This issue tracks the agent-facing write verb decided in the 2026-06-22 review session. Shape and provenance model are settled (see Decisions below); signatures here are the implementation target.
This blocks the session-retro skill — that skill emits findings that must revise prior notes, and without a supersede verb it reproduces exactly the duplicate-and-contradict failure documented in #44.
Decisions taken (2026-06-22)
.history/sidecar, no in-tree tombstone. The prior version is recoverable from git; superseded content is not kept queryable. Frontmatter stamps the supersession for human/agent legibility.brain_update(andbrain_write) return the landed id + content hash, giving agents read-after-write confirmation for free on the write path. A thinbrain_get(id)covers the create-path / get-by-id half of theverify_write_landedgap (#44 cluster 2, 4/4 mismatch) without its own issue.Out of scope here: semantic read (#44 cluster 3),
brain_answertrust (#44 cluster 4), the human promote half (#38), consumer-split redesign (deferred per #43).Primary tool:
brain_updateSupersede an existing note in place: replace body, rewrite frontmatter, re-sync embedding.
Behaviour:
wing/hall/slug(or full path). If no such note exists → error (do not silently create; that'sbrain_write's job). Clear error so the agent can fall back to create.content.updated_at(now),supersedes(priorcontent_hash), andsupersede_reasonifreasongiven. Preservewing,hall,created_at, and any custom fields.id,path,content_hashof the landed note,superseded: true.Secondary: read-after-write on the create path
brain_writereturn contract extended to include{ id, path, content_hash }(currently does not return a stable handle — this is why agents lexically re-query their own fresh note).brain_getis read-only — LOW risk tier.brain_updateis a content-replace — MEDIUM risk tier (reversible via git; the no-silent-create guard prevents accidental new-note creation).Layout note
The promote-half issue (#38) and the older specs assume
brain/raw/→brain/wiki/. Per #43 the current curation-state split isknowledge/(unreviewed) →wiki/(promoted).brain_updateoperates on notes wherever they live (bothknowledge/andwiki/) — it is a revise-in-place verb, orthogonal to the curation-state move. Reconcile path conventions with #38 at implementation time.Acceptance criteria
brain_updatesupersedes an existing note: body replaced,updated_at/supersedesfrontmatter stamped, prior fields preservedbrain_updateon a non-existent target → clear error, no note created (no silent create)brain_updatereturns{ id, path, content_hash }brain_writereturn contract extended to include{ id, path, content_hash }brain_get(id|path)returns the landed note (frontmatter + body) — read-after-write confirmation without lexical re-querytask checkpassesingestion/internal/mcp/server.goRelated
brain_pending/brain_promote); complementary, not duplicated heretbd_ship) — same intent-vs-mechanism fix applied to the gitea repoDispatch — koala-Claude implementation handoff
Implement this issue in
mathias/hyperguild. The decisions in the body above are settled — don't relitigate the shape. Verify everything against the brain MCP code as you go. This unblocks the session-retro skill, so correctness of the supersede + re-index path is the whole point — a half-workingbrain_updatethat doesn't refresh retrieval is worse than none.Where the code lives
All in
ingestion/internal/mcp/:server.go— package doc comment (lists tools),handleCalldispatch switch, server wiring (WithHybridRetrieval= embedder+pgvector,WithGraph= entity/edge re-index).handlers.go—tools()descriptor list, plus thebrainWrite/brainQuery/brainTunnelhandlers to mirror. NotebrainWritecallsapi.WriteNote(...)and currently returns only{"path": relPath}.handlers_test.go,integration_test.go— test patterns to follow.api.WriteNoteiningestion/internal/api/. Find its read/resolve counterpart (or add one) for the supersede path.A tool is registered in three places — all three must be updated for any new tool: (1) the
tools()descriptor slice, (2) thehandleCallswitch, (3) the package doc comment at the top ofserver.go.Step 0 — the load-bearing investigation, do this FIRST
The supersede contract requires retrieval to reflect the new content after an update. Two indexes are downstream of a write:
Graph — handled explicitly in-handler via
s.indexInGraph(ctx, op, relPath). Easy:brain_updatecalls it the same waybrain_writedoes.pgvector embeddings —
brain_queryreadss.vector+s.embedderlive (search.QueryContext), but there is no visible embedding re-index call inside the write handler. Before writing any code, trace how embeddings get (re)built for a note: is it a watcher, a separate sync job, an on-read embed, or somethingapi.WriteNotetriggers indirectly?Report what you find before implementing the re-sync. If embeddings are rebuilt out-of-band (e.g. a periodic sync), then "re-sync on update" may mean "ensure the out-of-band job re-embeds the changed file" rather than an in-handler call — and the acceptance criterion "a query reflects the new content, not the old" needs to be satisfied against whatever that real mechanism is. Do not invent an embedding call that doesn't match the existing architecture. If embeddings are only ever built by a separate process and
brain_updategenuinely can't trigger a refresh, stop and flag it in a PR comment — that's a design question for Mathias, not something to paper over.What to build
1.
brain_update— supersede-by-slug, whole-note replaceslug(or full path),wing+hall(required if slug isn't a full path),content(new full body),reason(optional).brain_write's job). Error must be clear enough that a caller knows to fall back tobrain_write.content. Rewrite frontmatter: setupdated_at(now, UTC),supersedes(prior content hash),supersede_reasonif given. Preservewing,hall,created_at, and any custom frontmatter fields.s.indexInGraph). Rebuild the wing_index.mdif it lands in the structured wiki (mirrorbrainWrite'sBuildWingIndexcall). Auto-tunnel optional on update — matchbrain_writeunless it causes duplicate links.{ id, path, content_hash, superseded: true }.2.
brain_get— fetch by id or path (NEW, read-only)idORpath(one required).{ id, path, content_hash, frontmatter, body }.3. Extend
brain_writereturn contract{"path": relPath}. Addidandcontent_hashso the create path also gives a stable handle (this is why agents currently lexically re-query their own fresh notes).pathfor backward compat; add fields, don't rename.Decide and state: what is
id, what iscontent_hash?There's no stable note id in the current surface (paths are the de-facto handle). Pick the simplest correct thing — likely
id= the relative path (or a slug-derived stable key) andcontent_hash= sha256 of the post-write body. Whatever you choose,brain_get(id)andbrain_get(path)must both resolve, and theidreturned by write/update must round-trip throughbrain_get. State your choice in the PR description.Conventions
main.task checkis the gate — must pass before the PR is mergeable.handlers_test.go):brain_getby idbrain_getby pathbrain_writereturns{id, path, content_hash}tools(), andhandleCallfor both new tools.Layout note
Per #43 the curation-state split is
knowledge/(unreviewed) →wiki/(promoted).brain_updateoperates on notes wherever they live — revise-in-place, orthogonal to the curation-state move (#38'sbrain_promoteis the move verb, separate issue). Don't conflate them.Open the PR, don't auto-merge
Open a PR against
mainreferencing #45 and post your Step 0 embedding findings as a PR comment. Mathias reviews and merges from claude.ai. If Step 0 surfaces that the re-sync can't be done in-handler, raise it in the PR before building the rest — the one thing worth blocking on.Report back
brain_updatetriggers it).id/content_hashchoice.task checkresult.Implemented — PR #46 (open, not merged per dispatch)
PR: #46
Step 0 — embedding re-index (load-bearing finding)
No MCP write handler re-embeds in-handler — not even
brain_write. TheServerholds only the read side (s.vector,s.embedder); it has novectorstore.Store(Upsert/Delete), so it cannot embed in-handler. Embeddings refresh strictly out-of-band:vectorstore.StartSync— background ticker (cmd/server/main.go:301, default 5 min), mtime-driven: re-embeds any file whose mtime exceeds its oldest chunk'supdated_at, deleting stale chunks first.POST /backfill-embeddings— manual full sync.So "re-sync on update" = ensure the out-of-band Sync re-embeds the changed file.
brain_updaterewrites the note viaos.WriteFile→ mtime advances → next Sync pass re-embeds the new body + drops the stale chunk. Exactly howbrain_writebehaves. I did not add a divergent in-handler embed call. Verified against the real mechanism inTestBrainUpdateReembedsNewContent(no stub of the re-index path).Design note: semantic/vector retrieval has a ≤5 min staleness window post-supersede; BM25 +
brain_getare immediate. Deferred as #47 (wire avectorstore.Storeinto the MCP server only if a consumer needs sub-5-min vector read-after-write).id/content_hashchoiceid= brain-relative slash path (the de-facto handle).brain_get(id)andbrain_get(path)resolve identically; the id from write/update round-trips throughbrain_get.content_hash= lowercase hex sha256 of the full file bytes on disk.brain_getrecomputes → identical;brain_updatehashes the prior file and stamps it assupersedes.Acceptance criteria — all met
brain_updatesupersedes: body replaced,updated_at/supersedesstamped, prior fields (created_at/wing/hall/custom) preservedbrain_updatereturns{id, path, content_hash}(+superseded)brain_writeextended{path}→{id, path, content_hash}(path kept for compat)brain_get(id|path)returns frontmatter + bodytask checkpasses (exit 0; lint 0 issues, vet clean,-racetests green)tools(),handleCall, package docCommits (trunk-style, one logical change each)
feat(brain): add UpdateNote/ReadNote supersede primitives + frontmatter editorfeat(mcp): register brain_update + brain_get, extend brain_write handlechore(context): re-sync derived adapters—task checkcontext gate flagged pre-existing root-AGENT.mddrift; committing the regen is the documented remedy, not a content change here.No tag/SemVer bump — happens on merge to main from claude.ai, not on the branch.
Follow-up filed
Brain
wiki/hyperguild/facts/brain-mcp-embeddings-out-of-band-sync.md— the "write handlers don't re-embed; Sync is out-of-band + mtime-driven" footgun, so the next person adding a write verb doesn't re-derive it.Unmet criteria: none.