Version
LifeOS 7.40.4 / Cortex (memory reviewer)
What is broken
dispatchItems hands every memory item of a reviewer run to memoryAdd in turn, and each op:"set" write is judged only against the file the previous item left. The reviewer prompt asks for one memory item per actor, but nothing checks it. So one run that carries several op:"set" items for the same actor can walk the hot-layer file down one entry per write: the erosion guard (net loss of 2 or more refused) and the shrink guard pass every step, and below 10 entries neither guard applies. On a clean 7.40.4 tree, one run of 40 items takes a 48-entry file to 1 entry with 40 successes, no failure and no guard refusal, so the run row looks healthy and no health check reacts. The same drop in a single item is refused. Every step is logged in memory-writes.jsonl with its evictions, so the entries can be restored by hand. I have not seen a real run do this. The reviewer reads conversation text, so a confused model, or text in the conversation that steers it, could produce such a run.
Related: #1905 (the consolidation lift). With that shape applied locally, one flagged write may go from 39+ entries straight to the 24 floor, and the same walk took 17 items instead of 40.
Where (file:line)
LIFEOS/TOOLS/MemoryReviewer.ts:499 (dispatchItems): every item goes to memoryAdd at :524; there is no per-actor check.
LIFEOS/TOOLS/MemoryReviewer.ts:248: "Emit ONE memory item per actor" is a prompt instruction only.
LIFEOS/TOOLS/MemoryWriter.ts:576: both guards apply only when the prior file has 10 or more entries; :600-602: EROSION_LIMIT = 2 is judged per write.
Repro on a clean tree
cd ~/.claude
T=$(mktemp -d); U="$T/.config/LIFEOS/USER"
mkdir -p "$U/PRINCIPAL" "$U/DIGITAL_ASSISTANT" "$U/MEMORY/OBSERVABILITY" "$T/.claude/LIFEOS"
ln -s "$U" "$T/.claude/LIFEOS/USER"; ln -s "$U/MEMORY" "$T/.claude/LIFEOS/MEMORY"
{ printf -- '---\nschema_version: 1\n---\n\n<!-- BEGIN ENTRIES -->\n'
for i in $(seq 1 48); do echo "PREFERENCE: synthetic fact $i ~explicit"; done
printf '<!-- END ENTRIES -->\n'; } > "$U/PRINCIPAL/PRINCIPAL_MEMORY.md"
TOOLS="$PWD/LIFEOS/TOOLS" env -u LIFEOS_DIR -u LIFEOS_CONFIG_DIR HOME="$T" bun -e '
const { readFileSync, writeFileSync } = await import("node:fs");
const { parseReviewerOutput, dispatchItems } = await import(process.env.TOOLS + "/MemoryReviewer.ts");
const { read } = await import(process.env.TOOLS + "/MemoryWriter.ts");
const file = process.env.HOME + "/.claude/LIFEOS/USER/PRINCIPAL/PRINCIPAL_MEMORY.md";
const original = readFileSync(file, "utf8");
const all = read(file).entries;
// One reviewer output: op:"set" items for the same actor, each one entry shorter, then a jump below 10.
const sizes = [...Array.from({ length: 39 }, (_, k) => 47 - k), 1];
const items = sizes.map((n) => ({ type: "memory", actor: "principal", op: "set", entries: all.slice(0, n) }));
const run = dispatchItems(parseReviewerOutput(JSON.stringify({ items })).output.items).summary;
console.log(`before: ${all.length} entries; one run with ${items.length} op:"set" items for one actor`);
console.log(`succeeded=${run.succeeded} failed=${run.failed} skipped_guard=${run.skipped_guard}; after: ${read(file).entries.length} entries`);
// Contrast: the same drop in ONE item is refused.
writeFileSync(file, original);
const one = dispatchItems([{ type: "memory", actor: "principal", op: "set", entries: all.slice(0, 1) }]).summary;
console.log(`same drop in one item: failed=${one.failed} (${one.failures[0]?.error.split(" — ")[0]}); after: ${read(file).entries.length} entries`);
'
rm -rf "$T"
It builds a throwaway HOME with the LIFEOS/USER and LIFEOS/MEMORY links the boundary guard expects, writes 48 synthetic entries, and sends one reviewer output with 40 op:"set" items for the principal through the shipped parseReviewerOutput and dispatchItems. Nothing outside the temp folder is written; LIFEOS_DIR and LIFEOS_CONFIG_DIR are unset because they would point the tools at the real install.
Negative control
On unpatched 7.40.4 (the shipped payload in skills/LifeOS/install/LIFEOS/TOOLS/, byte-identical to main at 5e2f2e8 for MemoryReviewer.ts, MemoryWriter.ts and MemorySystem.ts), the repro prints:
before: 48 entries; one run with 40 op:"set" items for one actor
succeeded=40 failed=0 skipped_guard=0; after: 1 entries
same drop in one item: failed=1 (EWRITE_FAILED: MemoryWriter rejected: ESUSPECT_SHRINK); after: 48 entries
One run walks the file from 48 entries to 1 with no failure and no refusal. The same drop as a single item is refused and the file keeps its 48 entries.
Suggested fix
Untested. Let dispatchItems (or parseReviewerOutput) apply at most one op:"set" memory item per actor per run: keep the last one and record the others in skips as skipped_guard with a reason, so CortexHealth's dispatch-summary check (total === succeeded + skipped_guard) still passes. The per-write guards, and the #1905 floor, then bound a whole run. The prompt already asks for exactly this (MemoryReviewer.ts:248).
Before submitting
Version
LifeOS 7.40.4 / Cortex (memory reviewer)
What is broken
dispatchItemshands every memory item of a reviewer run tomemoryAddin turn, and each op:"set" write is judged only against the file the previous item left. The reviewer prompt asks for one memory item per actor, but nothing checks it. So one run that carries several op:"set" items for the same actor can walk the hot-layer file down one entry per write: the erosion guard (net loss of 2 or more refused) and the shrink guard pass every step, and below 10 entries neither guard applies. On a clean 7.40.4 tree, one run of 40 items takes a 48-entry file to 1 entry with 40 successes, no failure and no guard refusal, so the run row looks healthy and no health check reacts. The same drop in a single item is refused. Every step is logged inmemory-writes.jsonlwith its evictions, so the entries can be restored by hand. I have not seen a real run do this. The reviewer reads conversation text, so a confused model, or text in the conversation that steers it, could produce such a run.Related: #1905 (the
consolidationlift). With that shape applied locally, one flagged write may go from 39+ entries straight to the 24 floor, and the same walk took 17 items instead of 40.Where (file:line)
LIFEOS/TOOLS/MemoryReviewer.ts:499(dispatchItems): every item goes tomemoryAddat:524; there is no per-actor check.LIFEOS/TOOLS/MemoryReviewer.ts:248: "Emit ONE memory item per actor" is a prompt instruction only.LIFEOS/TOOLS/MemoryWriter.ts:576: both guards apply only when the prior file has 10 or more entries;:600-602:EROSION_LIMIT = 2is judged per write.Repro on a clean tree
It builds a throwaway HOME with the
LIFEOS/USERandLIFEOS/MEMORYlinks the boundary guard expects, writes 48 synthetic entries, and sends one reviewer output with 40 op:"set" items for the principal through the shippedparseReviewerOutputanddispatchItems. Nothing outside the temp folder is written;LIFEOS_DIRandLIFEOS_CONFIG_DIRare unset because they would point the tools at the real install.Negative control
On unpatched 7.40.4 (the shipped payload in
skills/LifeOS/install/LIFEOS/TOOLS/, byte-identical tomainat 5e2f2e8 forMemoryReviewer.ts,MemoryWriter.tsandMemorySystem.ts), the repro prints:One run walks the file from 48 entries to 1 with no failure and no refusal. The same drop as a single item is refused and the file keeps its 48 entries.
Suggested fix
Untested. Let
dispatchItems(orparseReviewerOutput) apply at most one op:"set" memory item per actor per run: keep the last one and record the others inskipsasskipped_guardwith a reason, so CortexHealth's dispatch-summary check (total === succeeded + skipped_guard) still passes. The per-write guards, and the #1905 floor, then bound a whole run. The prompt already asks for exactly this (MemoryReviewer.ts:248).Before submitting