A 400-byte cap crashed my memory daemon on one emoji
Two memory bugs with the same shape: a truncation that counted bytes, and a fact verifier whose 'approved everything' looked identical to 'never ran'. With a five-minute check for your own stack.
Every agent memory system trims text somewhere. Facts get capped before they go into a prompt. Summaries get cut to fit a budget. Recall blocks get sliced to a length. The cut is usually one line, written months ago, and it takes a number that somebody assumed was characters. It was bytes. Or UTF-16 code units. The first user who puts an em dash or a party emoji near the limit finds out which.
That is one of two bugs I hit this week while moving Vodou’s memory graph into the console so that memory is one store served by one process. The other one is about a guard that ran perfectly and left no trace. They have the same shape, and that shape is the point of this post.
The move: one graph, one process, and 49,338 chunks that had never been deduplicated
The capability itself is unglamorous. The brain graph (the map of facts, their groups, and which twin retired which) used to live in a separate process with its own database handle. It now lives inside the console, reading through the same API as the memory list, the vault rules and the document library. The public side is in MCP-servers/Vodou-Console/src/brain/queries.ts and src/api/brain.ts, with a drift test in src/__tests__/brain-queries-drift.test.ts so the graph queries and the list queries cannot quietly disagree about what a live chunk is.
For a person using it: one search box, one theme, one process to restart, and a map whose filter searches memories instead of filenames (that one was a bug too). The engine underneath it gained fact groups and a reconcile step that decides, when two facts say the same thing, which one lives.
Getting the graph to tell the truth meant fixing what it displayed. Three of those fixes are worth your time.
Exit 99, and the hook said “memory DEGRADED” instead of “the daemon is dead”
Reconcile runs over every fact it compares, and it trims long facts to 400 for display in the prompt. The cap was written as a byte length, and the standard library truncate in that language asserts when the index lands inside a multi-byte character. A fact with an accent, an em dash or an emoji near position 400 took the daemon down with exit 99.
What made it expensive was not the panic. It was the symptom. The worker process survived, so from the outside memory looked empty rather than crashed. The prompt hook reported DEGRADED and connection refused. I spent time on sockets while assertion failed: is_char_boundary sat in the system log. The bug had been latent since 2026-07-16.
The fix is one line: cut on a character boundary, and make the cap 400 characters, which is what the call site always meant. It was trimming for display, not enforcing a byte budget. The regression test fills the string with 2-byte and 4-byte characters, both of which panic under the old code.
This is not a Vodou bug. It is the industry’s bug this year. OpenFang hit it at a fixed byte offset with an em dash. AionUi hit it on a 25,000-byte MEMORY.md cap with CJK text, where the panic killed the agent at session start and every later start too. Mastra hit the UTF-16 version: truncation split a surrogate pair and Anthropic’s JSON parser rejected the whole request. OpenClaw shipped the same fix for recall summaries prepended to the prompt. Four projects, four months, one line each.
2,437 backwards clocks, and the reconciler that kept the poorer twin
The second fix started as a user-visible failure: Vodou had learned my sons’ names four times and thrown them away four times, keeping “married, two sons” every time. Chasing it found two unrelated bugs wearing one symptom.
The first was inert. 2,437 of 3,281 retired chunks had a retirement time earlier than their creation time. Re-syncing a memory file deletes and re-inserts its chunks, so creation is re-stamped from the file while retirement is carried over from the deleted row. Two columns, two sources, no invariant. I backfilled them and then checked all 33 readers of that column: every one tests for NULL, none compares it as a time. So it changed nothing anyone saw. I recorded it anyway, so nobody hunts for a symptom that does not exist.
The second one bit. Reconcile’s duplicate arm was first-writer-wins. The incumbent survived, the newcomer was hidden. A later, richer phrasing lost purely for arriving second. The fix retires the fact that says less, not the one that arrived later.
Underneath both sat a third problem: near-duplicate demotion had never completed on this corpus at all. The scan is O(n²) over 49,338 chunks, and the CLI watchdog (progress-based since 2026-08-22, so slow-but-advancing work survives) killed it every run with “no progress since start, 90s”. A lane that never beats is indistinguishable from a wedged one. The fix was to beat every 512 chunks, not to add a 17th exemption. First completed scan: 428,484 pairs over cosine, 8,407 passing overlap, 980 groups, 3,292 demoted, 20 value conflicts queued, 147 seconds. Retrieval-bench moved with it.
Zero verifier lines after 120 facts, and both explanations fit
The bridge’s 4B extractor turned a turn that said “just saying hi” into a preference fact about reply length, and wrote it. One model read the transcript, asserted what it meant, and its assertion was the verification. Extraction now runs each candidate fact past a separate completion that sees only the source and the claim and answers one question: does the source say this. Not “is this plausible”. Plausible-given-the-source is exactly the trap.
Then I went to measure the rejection rate. Five conversations, 120 facts written, zero verifier log lines. I spent several steps hunting a wiring failure that did not exist. The verifier had run the whole time. It only logged on rejection, so “ran and approved everything” and “never ran” produced byte-identical evidence: nothing.
I had built, into the instrument whose purpose is catching unchecked claims, a log that could not distinguish success from absence. It now emits one line per batch, unconditionally:
[extract-verify] mode=Log checked=4 kept=4 unsupported=0 unknown=0
An absent line now means the verifier did not run. unsupported and unknown are separate numbers on purpose: a high unknown means the checker could not reach its provider, and must never be read as “the facts were clean”. First real rate from a live turn: 8 checked, 0 unsupported, 0 unknown.
The class: a cap in the wrong unit, and a guard whose silence means two things
Both bugs are checkable properties, not advice.
First: every truncation applied to user text must count in the unit the consumer of the text counts in. A prompt-display cap is characters. A storage cap is bytes. A JSON-over-HTTP cap must never split a UTF-16 surrogate. If the cap’s unit and the cut’s unit differ, the code is wrong regardless of whether it has fired yet.
Second: a guard that gates writes must produce evidence on every invocation, including approvals. If the set of observable outputs for “ran and passed everything” equals the set for “did not run”, the guard is unobservable and its rejection rate is unmeasurable. This is the same property as a health check that returns 200 from a stranger’s process.
Run this on your own store: an emoji at the cap, and a grep for silent guards
Find your cuts. In a Rust, Go, or C tree:
grep -rnE '\.truncate\(|\[\.\.[A-Za-z_0-9]+\]|\[:[A-Za-z_0-9]+\]' --include='*.rs' --include='*.go' src/ | grep -v test
In a TypeScript or Python tree:
grep -rnE '\.slice\(0, ?[A-Za-z_0-9]+\)|\.substring\(0|\[:[A-Za-z_0-9]+\]' --include='*.ts' --include='*.js' --include='*.py' src/ | grep -v test
Each hit is a cap. For each one, ask what unit the number is in and what unit the slice is in. Then feed the function a string that puts a 4-byte character exactly on the boundary. In Node, this is the whole test:
const cap = 5;
const s = "abcd🔥ef";
const cut = s.slice(0, cap);
console.log(JSON.stringify(cut), cut.isWellFormed());
Failing output is "abcd\ud83d" false: a lone high surrogate that JSON.stringify will happily emit and a strict server-side parser will reject. Passing output ends in a whole character and prints true. In Python, s.encode()[:cap].decode() raises UnicodeDecodeError on a failing cap and returns cleanly on a passing one. If your code path is Rust, use the char-boundary-aware cut and write the test with é and 🙂 fills; both panic under a byte cut.
For the second property, take your write-side guard (the schema validator, the PII redactor, the fact verifier, whatever stands between a model’s assertion and your store) and run this against its log after real traffic:
grep -c 'verify' app.log; grep -c 'INSERT INTO facts' app.log
If the second number is large and the first is zero, you have one of two situations and cannot tell which. Passing looks like a guard line count that is at least the batch count, with an explicit approved count in each line. Failing looks like silence, and silence is what you saw before you looked.
What the memory guides say about consolidation, and the two things they skip
The memory-architecture literature is good on tiers. The Geodocs pattern spec lays out working, episodic, semantic and procedural memory with consolidation and eviction rules. The Engineering Playbook maps the same three flavours onto storage choices. Pockit’s production guide is right that context windows are not memory.
None of them say who verifies the extractor. Consolidation is described as a step, not as an assertion that needs a second reader. And none mention that the prompt you consolidate into gets truncated somewhere, and that the truncation is where four projects fell over this year. Tiers are the easy part. The seam between “the model said this” and “the store now holds this” is where memory quality is decided, and it needs both a checker and a log of the checker.
Still open: the checker is graded on four cases, not on a corpus
The verifier has been checked against four discriminating cases and one live turn of eight facts. That is enough to know it runs and to know its log is honest. It is not enough to know its false-approval rate on a month of real transcripts, and “0 unsupported” on eight facts is not a rate. Until a proper labelled set exists, the number to watch is unknown, because that one tells me when the checker stopped being able to check.