Skip to content

Memory reviewer: one run may apply many op:"set" items for the same actor, so the per-write erosion and shrink guards do not bound a run #2300

Description

@rain77-work

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

  • I searched open and closed issues for this defect.
  • The repro runs against a clean tree of the version above, not against my modified install.
  • I removed personal data from the pasted output — real names, absolute home paths, tokens, my own content.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions