A search against my own memory store returned exactly the fact I asked for, at 0.915 cosine, with the cross-encoder firing at 0.9997, and the fact was a lie about where it came from.
Here is the retrieval, trimmed:
{
"chunk_id": "memory/2026-08-26.md:6636:88b5b122",
"chunk_tag": "GOTCHA",
"scope_label": "Claude Code",
"score": 1.4862698965708403,
"score_breakdown": {
"cosine": 0.91516219283973,
"rerank": "fired",
"reranker_logit": 0.9996613069256256
}
}
Retrieval was not the problem. Retrieval was doing an excellent job of surfacing a fact that had been filed under someone else's heading. Byte offset 6636 in memory/2026-08-26.md was an extraction fact sitting inside a section called ## Run log, which is written by a completely different process, for a completely different purpose. Its neighbor at 6679, 43 bytes later, was misfiled the same way.
Nothing was lost. Nothing was corrupted. Every byte both writers emitted was present, in order, exactly once. The file was still valid Markdown. It just said something untrue.
Both writes landed, both were atomic, and the fact was still misfiled
Two processes append to my daily memory file. One is the ordinary extractor, which pulls facts out of a session and files them under ## Gateway extraction. The other is a run-note writer that logs graph runs under ## Run log. Both open the file in append mode. Both write small bullets. Neither has ever dropped a byte.
The daily file is a normal human-readable Markdown document, on purpose. It is the read side of the memory system: I open it, a person reads it, and a chunker indexes it. That last part matters, because most chunkers keep the enclosing heading in the chunk text so the embedding has some context. Mine does. So when a fact lands under the wrong heading, the wrong heading is embedded into the fact, stored in SQLite, and served back at 0.915 cosine forever.
The symptom, in the words I wrote down at 4am and later found in my own store: every later append by the ordinary extractor lands inside that section and inherits its heading.
I spent three days on the chunker and shipped an flock that fixed nothing
First theory: the chunker was mis-splitting sections. Boundary detection in Markdown is genuinely hard, and there is a well documented family of ways to get it wrong. markdown-patch issue #10 is the canonical example: boundary detection that matches any ^#{1,6}\s line without tracking code-span or table-cell context, so a heading-shaped string inside a table cell gets promoted to a real heading and the file corrupts on write. I read that issue, believed it, and went looking for the same bug in mine. It was not there. My chunker was reading the file correctly. The file was wrong.
Second theory: the tagger. [GOTCHA] versus [PATTERN] versus [DECISION] had been unreliable before. I audited the tags. They were fine.
Third theory, and the one that cost me the most: a write race. Two processes, one file, this is textbook. So I read the textbook. Stack Overflow's canonical answer on concurrent appends and the Unix SE version both say the same correct thing: with O_APPEND, seek-to-end plus write is atomic, so writers do not clobber each other as long as writes stay small. Paul Khuong's appending to a log builds an entire wait-free log on that guarantee and then spends the rest of the piece on the parts the guarantee does not cover.
I added a lock anyway. The bug survived it, obviously, because there was never a race. All of that advice is about bytes. Not one line of it covers the case where both writes land intact, in order, and the second writer's bytes silently acquire the first writer's meaning.
The closest published thing to my actual bug is claude-code issue #58736, on concurrent sessions writing a shared MEMORY.md index with no coordination. It gets the shape exactly right: same repo, same file, multiple appenders, no documented write-coordination behavior. What it misses is which failure hurts more. That issue is about a lost index line. A lost line is loud: the entry is gone, you go looking for it, you notice. My line was not lost. It was present, indexed, embedded, retrievable at 0.915, and attributed to the wrong writer. Nothing looked broken, which is why it took three days.
June chunks carried ## Gateway extraction. August chunks carried ## Run log.
The measurement that ended it was not on the file. It was on the store, asking what heading each chunk's text actually starts with, over time:
SELECT substr(created_at, 1, 7) AS month,
substr(text, 1, instr(text || char(10), char(10)) - 1) AS first_line,
COUNT(*) AS n
FROM chunks
WHERE path LIKE 'memory/2026-%.md'
AND text LIKE '##%'
GROUP BY 1, 2
ORDER BY 1;
Chunks from June came back like this, heading intact, exactly as designed:
2026-06 | ## Gateway extraction
Chunks from late August did not carry that heading at all. In the sample I pulled, every pre-August chunk that carried a heading carried ## Gateway extraction. Not one August chunk did.
The cause is embarrassingly simple once you see it. A Markdown heading is an opening bracket with no closing bracket. ## Run log does not end. It ends when the next heading begins, and if no next heading is ever written, it owns the rest of the file forever. The run-note writer emits its heading once per day and then appends bare bullets. The extractor does the same. Whichever of them writes its heading last owns every subsequent line from both writers. On 2026-08-26 that was ## Run log, which is why bytes 6636 and 6679 belong to it. On 2026-08-27 the same thing happened by offset 1071.
Append is not a byte operation on a structured document. It is a semantic operation that inherits whatever context the previous writer left open.
The fix is a tail read, a comparison, and a trailing newline nobody remembers
Each writer now re-asserts its own heading unless that heading is already the one in force. In Node terms, because the engine side of this is not mine to publish:
async function appendOwned(file: string, heading: string, lines: string[]) {
const tail = await readTail(file, 4096); // last 4 KB is plenty
const inForce = lastHeadingOutsideFences(tail); // skips ```
{% endraw %}
blocks
const needsNewline = tail.length > 0 && !tail.endsWith("\n");
const body =
(needsNewline ? "\n" : "") +
(inForce === heading ? "" : {% raw %}`\n${heading}\n\n`{% endraw %}) +
lines.map(l => l + "\n").join("");
await fs.appendFile(file, body); // still O_APPEND, still atomic
}
{% raw %}
Three details earn their keep. lastHeadingOutsideFences tracks fence state, because a ## something inside a code block is not a heading, which is the same class of mistake markdown-patch #10 documents. The needsNewline check exists because logseq-matryca-parser issue #72 is right that plenty of editors leave a file without a trailing newline, and appending \n## Gateway extraction onto an unterminated bullet produces body text, not a heading. And the tail read is technically racy: another writer can append between my read and my write. The worst outcome is a duplicate heading, which is idempotent on the read side. Misfiling is not.
Two writers, one file, and a heading that stays open until someone closes it
The general failure: concurrent appenders to a shared human-readable document with no section ownership. Any format where structure is expressed by an opening marker with no closing marker inherits it. Markdown changelogs written by both a release bot and a human. Agent scratchpad and memory files that several sessions append to at once. Shared daily-note vaults in Obsidian or Logseq. Append-only YAML and TOML written by more than one service, where indentation level is the open bracket instead of a heading.
The invariant, checkable against a codebase rather than a vibe:
A section's contents must be determined by the writer that owns the section, not by whoever appended last. If deleting an unrelated writer would change which heading your entry falls under, your document has no section ownership.
Here is the five-minute check, using nothing of mine. First reproduce it, so you know what you are looking for:
bash
cd "$(mktemp -d)"
printf '## Writer A\n\n- a1\n' > shared.md # A opens its section
printf '## Writer B\n\n- b1\n' >> shared.md # B opens its section
printf -- '- a2\n' >> shared.md # A appends again, no heading
awk '/^#+ /{h=$0; next} /^- /{print h" <- "$0}' shared.md
Failing output, which is what you get:
plaintext
## Writer A <- - a1
## Writer B <- - b1
## Writer B <- - a2
Passing output has ## Writer A <- - a2 on the last line. Now run the same attribution over your real file:
bash
awk '
/^
```/ { fence = !fence; next }
fence { next }
/^#{1,6} / { section = $0; next }
/^[-*] / { print section }
' NOTES.md | sort | uniq -c | sort -rn
Passing looks like counts spread across every section that has a writer. Failing looks like one section holding the large majority, usually whichever heading was created most recently, while the sections written by everyone else hold only the two or three lines from the moment they were created and nothing since.
If you index those files into a vector store, run the store-side version too, because that is where the damage becomes permanent:
SELECT date(created_at) AS day,
substr(text, 1, instr(text || char(10), char(10)) - 1) AS first_line,
COUNT(*) AS n
FROM chunks
WHERE path LIKE '%.md'
GROUP BY 1, 2
HAVING first_line LIKE '#%'
ORDER BY day DESC, n DESC;
Passing: each day shows several distinct headings, roughly matching your number of writers. Failing: on some specific day one heading takes over and the others stop appearing entirely. That day is when your second writer shipped.
The rule I would put in any codebase that writes structured text from more than one process: a writer that does not name its own section on every append is not appending to its section, it is appending to the file. Byte-level atomicity is a promise about bytes and it will be kept perfectly while your data quietly changes meaning. If you cannot point at the line of code where each writer re-asserts where it belongs, you do not have two writers. You have one document and a queue.
Source: Two writers, one Markdown file: your appends get misfiled by Chad Priest, from Building Vodou in Public.
Top comments (0)