fix: pipeline, backfill, watcher and archive writes go through the brain root (#158 part B) #195
Open
mathias
wants to merge 3 commits from
fix/pipeline-writes-158 into main
pull from: fix/pipeline-writes-158
merge into: :main
:main
:fix/pipeline-writes-158
:fix/edit-path-io-192
:fix/create-regular-188
:fix/guard-special-179
:fix/linkcheck-keeps-write-178
:fix/write-dir-redirect-173
:fix/tunnel-detect-174
:fix/pipeline-root-157
:w3/154-mcp-auth-wiring
:fix/tunnel-traversal
:test/in-brain-targets-171
:fix/brain-scan-root-157
:fix/graph-wiki-root-157
:fix/vectorstore-root-157
:fix/notes-root-157
:fix/search-root-157
:fix/brainwide-root-157
:fix/raw-symlinks-153
:feat/symlink-tripwire-157
:fix/rest-auth-149
:fix/123-brain-edit-envelope-check
:feat/106-page-aware-extraction
:fix/105-source-drift-on-main
:fix/104-surface-merge-conflicts-on-main
:agent/b1-compile-on-ingest
:agent/104-surface-merge-conflicts
:agent/105-source-drift
:agent/107-loud-unconfigured-alerting
:feat/brain-read-edit
:fix/capture-i1-refusal-names-target
:renovate/configure
:feat/consolidate-single-harness
:icebox/cmd-routing
:feat/brain-pending-promote
:fix/close-session-classification-refresh
:fix/capture-summarywriter
Labels
Clear labels
agent/ready
blocked/client
blocked/supplier
blocked/upstream
risk/high
risk/low
risk/medium
size/l
size/m
size/s
size/xs
status:dispatch
status:dispatched
status:review
status:reviewing
type/chore
type/decision
type/docs
type/feat
type/fix
type/ops
type/research
type/test
Meets the agent-ready contract; authorizes unattended execution
Waiting on the client
Waiting on a supplier
Waiting on an upstream project or vendor
Irreversible, security-relevant, or control plane
Reversible, local, test-covered
Touches shared state, deploys or data
Too big - split it
Under two days
Under half a day
Under an hour
Maintenance, no behaviour change
Human decision required; never agent/ready
Documentation only
New capability
Corrects broken behaviour
Infrastructure, deploy, runtime
Investigate; output is a finding, not code
Test coverage only
No labels
risk/high
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mathias/hyperguild#195
Reference in New Issue
Block a user
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.
Fixes #158 (part B; part A was #189). risk/high: control code, adversarial review required.
Problem
These writers still created or rewrote brain files by path:
buildAndWrite:os.MkdirAll+os.WriteFileof every page.BackfillRefs:os.WriteFileof each concept page. A page swapped for a symlink after the read was written through. The new race test overwrote an in-brain target before this change.raw/processedandraw/failedarchive copies (os.Create).processed/.failedmarkerslog.mdentry (os.OpenFile)cmd/server: the claude-session archive.Change
brainfs.PutRegular/AppendRegular.brainfs.PutFile/AppendFilecover one-off writers. The watcher file's call-site lint bans.Opencalls there soraw/reads stay onOpenRegular, and these helpers keep that intact.Tests (red before the fix)
TestProcessDirDoesNotWriteThroughASymlinkedArchiveDirOrLog, for both processed and failed. A pre-planted marker symlink makes the watcher skip the note, so the marker write itself is reachable only by a race.TestProcessDirWritesMarkersAndArchiveInRealDirectories: positive control.TestBackfillRefsNeverWritesThroughAPageSwappedForASymlink: the swap race, with a positive control.Not covered
🤖 Generated with Claude Code
Created via git-mcp on behalf of @mathias
pr-gatekeeper review — PR #195 (head
77c44f2): BLOCKEDVerified by execution against a scratch copy of the branch. CI run 9518 is green and
CI=1 task checkexits 0. Neither covers the findings below.Blocking
The log.md write has no test that reaches it. The test is named
TestProcessDirDoesNotWriteThroughASymlinkedArchiveDirOrLog. In itsfailedsubtest,raw/failedis symlinked, socopyFromRootfails andprocessFilereturns before it gets toappendWatcherLog. As a flip, I put the by-pathos.OpenFileappend back insideappendWatcherLog, and every test stayed green (watcher + extract). The call-site lint doesn't catch it either, becauseinternal/extract/callsites_test.gostill allowlistswatcher.go:appendWatcherLogforos.Open*, even though that function no longer opens anything. So the name claims the log is covered, and the test cannot reach the log (rule 9). Fix: test log.md with a realraw/failed, and remove the stale allowlist entry.The marker writes can be reached without a race, contrary to the test comment, and no test checks them. The comment says a pre-planted marker symlink makes the watcher skip the note. That is false for a dangling symlink:
os.Stat(path+".processed")follows it, gets ENOENT, and the note is processed. As a flip, I put both.processedand.failedback toos.WriteFile(path+...). All tests stayed green, and in that state a dangling marker link creates its target outside the brain. The PR's own code refuses this correctly, but nothing asserts that it does.When a marker write is refused, the watcher loops forever. With the PR code and a dangling
.processedlink, each interval writes a new timestamped archive copy intoraw/processed/<date>/: 4 rounds gave 4 copies. At the default 30s interval that is about 2,880 files a day per note, in a folder Syncthing syncs. With a dangling.failedlink, the LLM is called again every interval, andraw/failed/<f>pluslog.mdare rewritten or appended each time. That is unbounded model spend plus log growth. Before this PR the marker followed the link, which was the bug, but it did stop the loop. A refusal has to end in a terminal state, not a retry.When an archive copy is refused, the note is stuck and retried every interval. This happens with a symlinked
raw/failedor a FIFO atraw/failed/<f>: no.failedmarker gets written, so the LLM is called again every interval (3 calls in 3 rounds). It also happens with a symlinkedraw/processedorraw/processed/<date>: an error every interval, cheap only because of the body-hash early return. And it happens with no attacker for a note whose filename contains a backslash, which is legal on Linux and macOS.cleanRelrefuses it, so the note is never archived or marked and errors every interval. Before this PR that note was handled normally.Two converted writers have no assertion at all. For
buildAndWrite(pipeline.gowriteBrainFile), I reverted it toMkdirAll+os.WriteFileandgo test ./...stayed green. Pre-planted shapes are already refused by the inventory/reviseOrCreatereads, so this write only matters under a read-to-write swap, and no race test covers it. For thecmd/serverclaude-session archive, I reverted it toMkdirAll+os.WriteFileandgo test ./...stayed green. In both cases the code is correct but nothing would notice if it were removed.Non-blocking
copyFromRootnow doesio.ReadAllon the raw file. For.md/.txtthat doubles peak memory, sinceTextInalready reads the whole file. For.pdfit is new: previously both the extraction and the archive streamed it. There is no upstream size cap. Consider a streaming or size-cappedPutRegular..., empty host, backslash;a/bnests and stays inside the archive). The refusal returns an error, so the claudewatcher cursor never advances and that JSONL is retried every tick. The ids are a Claude Code UUID and the hostname, so this is low risk.os.WriteFileturned it red 3 of 3 times. Itsokcontrol counts nil returns, not writes.What passed
The probes (watcher end to end via
processDir, pipelineRunRaw,claudeSink.Ingest) covered a symlinked raw/processed, raw/failed and raw/processed/, both in-brain and outside; a symlinked log.md (with a real raw/failed); FIFOs at the archive, marker and log paths; and a symlinked or FIFO page. In every case nothing was created at a link target and nothing hung.-race -count=3passed on watcher, pipeline and brainfs.Label is
risk/high: merge is Mathias's call, so this is not a merge request.pr-gatekeeper review, round 2: PR #195 (head
1a2a93d): BLOCKEDAll findings below were verified by execution on a scratch copy of the branch. CI run 9538 is green.
CI=1 task checkexits 0.-race -count=3passes on watcher, pipeline, brainfs and cmd/server. Green covers none of the blocking finding.Round-1 findings now closed (each flip goes red)
os.OpenFileturns bothTestProcessDirDoesNotAppendTheLogThroughASymlinkand the call-site lint red..processedmarker check. Changing Lstat back to Stat turnsTestProcessDirTreatsAnyEntryAtTheMarkerPathAsAMarkerred.TestProcessDirDoesNotRetryANoteWhoseArchiveCopyIsRefusedred.TestProcessDirProcessesANoteWithABackslashInItsNamered.TestBuildAndWriteNeverWritesThroughAPageSwappedForASymlinkgoes red, 3 of 3.TestClaudeSinkDoesNotWriteThroughASymlinkedArchiveDirgoes red.Blocking
No test covers the marker writes, or the new rule that a refused marker write is final. It now takes a race to reach the marker write. But the race window is the whole
pipeline.Run, so in production it lasts as long as an LLM call. A test can hit it deterministically: plant the entry at the marker path from inside the fakeComplete. I verified that against the PR code. Both.processedand.failedare refused, nothing is created at the link target, and the note is remembered (1 call in 3 rounds).No test in the PR does this, so these flips all stay green:
.processedor.failedmarker written back by path (os.WriteFile(path+...)). This is the #158 write-through itself. The probe above shows a file created at the link target..failedrefusal return dropped back to log-only. Suppose the planted marker entry is then removed (symlinkguard removes such entries). The LLM is called again every round: 3 calls in 3 rounds, which is the round-1 loop. The PR fixes this in code but has no test for it..processedrefusal return dropped back to log-only. Also green.Fix: add a watcher test that plants the marker entry during
Complete, for both.processedand.failed. Assert that nothing is created at the target, and that the note is not retried after the entry goes away.Non-blocking
.failedcheck reverted to Stat. This stays green. The refused-note memory contains the result: 1 call, nothing written outside. A one-line test case would pin it.cleanRelno longer refuses\. That is right on Linux. But it loosens a shared primitive behind every brainfs reader and writer, not just the watcher. No brainfs-level test pins either behaviour. A test should state the intent, so nobody "restores" the refusal later.io.ReadAll). It is still not streamed. For.md/.txtthis doubles peak memory, since TextIn already reads the whole file. For.pdfit is a new full in-memory copy, with no upstream size cap. Not blocking; file it as a follow-up (stream or cap).risk/high: the merge is Mathias's call.pr-gatekeeper review, round 3: PR #195 (head
22d0653): PASSAll results below come from running the code on a scratch copy of the branch. The round-3 diff changes tests only (3 test files); production code is the same as round 2.
Round-2 blocker: closed
TestProcessDirRefusesAMarkerLinkPlantedDuringTheRunAndDoesNotRetryplants the marker link during the run, as the round-2 review asked. Each of these flips turns it red:.processedwritten by path: red on theprocessed/outsideandprocessed/in-brainsubtests.failedwritten by path: red on bothfailed/*subtests.processedrefusal changed to log-only.failedrefusal changed to log-onlyEvery flip in the suite, re-run at this head
Each turns at least one test red:
.processedor.failedcheck changed from Lstat to StatcleanRel(red:TestReadAndPutRegularTreatABackslashAsAFilenameByte)End-to-end probes, re-run at this head
I ran
processDirwith a fake pipeline against nine planted shapes:Results over 3 rounds per shape:
Gates
22d0653: successCI=1 task check: exit 0, with 0--- FAILlines-race -count=3on watcher, pipeline, brainfs, cmd/server and extract: okFollow-ups the coordinator is filing as issues (non-blocking)
io.ReadAllmemory in the raw archive copy (.pdfhas no size cap)Not checked
Label is
risk/high, so the merge is Mathias's call, because merging deploys ingestion. This review does not merge.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.