Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions LifeOS/install/LIFEOS/DOCUMENTATION/Memory/MemorySystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ The reviewer runs in **curation mode**: it reads the file's CURRENT entries (`re

set-overwrite has a whole-file blast radius. `MemoryWriter.setEntries` computes a **catastrophic-shrink guard IN-LOCK** (against the just-read prior state, race-free): it blocks a result that is near-empty (`< 3` entries) OR a mass deletion (`>50%` dropped) **with zero additions**, while ALLOWING honest large consolidation (many drops accompanied by additions). `ESUSPECT_SHRINK` is the error; `allowDrastic` opts out for legitimate restores. (This guard exists because a cross-vendor audit wiped the live file with an empty `op:"set"` — it's now structurally impossible.) A second in-lock guard catches **slow erosion** (public issue #1761, @jacobo-ortiz): a single write that net-drops 2+ entries returns `ESUSPECT_EROSION` and logs the full delta to the write-log — re-transcription bleed passed the catastrophic checks every time (each write ~10% smaller, each carrying additions) until 12 durable rules died in 48h on a reporter's install. Deliberate consolidation passes with `allowDrastic: true`.

**Stale-base guard.** The reviewer's list is computed from a snapshot taken before minutes of inference, so a write landing in between (a manual curation pass, another writer) would be silently undone — and when the stale list is the larger one, neither guard above sees it. `read()` returns a `fingerprint` (sha256 of the valid entries); the reviewer passes its snapshot's fingerprint through `MemorySystem.add(item, { expectedFingerprint })` — an argument, never an item field, since items are model-facing — and `setEntries` recomputes it in-lock, refusing with `ESTALE_BASE` when the entries changed (frontmatter-only changes don't count). `op:"add"` guards with its own read; `op:"set"` callers without a fingerprint are unguarded, as before. The reviewer counts a refusal as a guard skip and retries at the next review. Refusals and lock timeouts (`ELOCK_HELD`, after a 2 s retry) are logged to `memory-writes.jsonl`, and `MemoryHealthCheck` reports them as `refused-writes` for 24h.

### Per-write snapshots (recoverability)

Every Tier-A write calls `snapshotBeforeWrite` — the prior file content is copied to a ring buffer at `MEMORY/OBSERVABILITY/memory-snapshots/<file>__<ts>.md` (last 30 per file) before the overwrite. So any individual autonomic write is reversible — not just at git-commit granularity. Recover via `bun LIFEOS/TOOLS/MemoryRestore.ts {list | restore <snap> | latest <principal|da>}`.
Expand Down
29 changes: 29 additions & 0 deletions LifeOS/install/LIFEOS/TOOLS/MemoryHealthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,35 @@ if (existsSync(REVIEWER_RUNS)) {
} catch { /* non-fatal */ }
}

// CHECK 10: refused writes — a write dropped because the file changed after its
// base was read (ESTALE_BASE) or the lock never cleared (ELOCK_HELD). The
// reviewer's stdio is ignored, so this log is the only place a refusal shows up.
// Warns for 24h, then clears on its own; a steady stream means the reviewer keeps
// losing races and its read→write window needs redesign.
{
const WRITES_FILE = join(OBS_DIR, "memory-writes.jsonl");
const nowMs = process.env.CORTEX_HEALTH_NOW ? Date.parse(process.env.CORTEX_HEALTH_NOW) : Date.now();
try {
// Whole file, filtered by time: a row count would let a busy day push a
// refusal out early. A future timestamp (clock skew) is not counted.
const refused = existsSync(WRITES_FILE)
? readFileSync(WRITES_FILE, "utf-8").trim().split("\n")
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter((r: any) => {
if (r?.rejected !== true || (r.rejection_code !== "ESTALE_BASE" && r.rejection_code !== "ELOCK_HELD")) return false;
const age = nowMs - Date.parse(r.ts);
return age >= 0 && age < 24 * 60 * 60 * 1000;
})
: [];
if (refused.length > 0) {
add("refused-writes", "warn", `${refused.length} memory write${refused.length === 1 ? "" : "s"} refused in 24h (stale or locked)`,
{ rows: refused.map((r: any) => ({ ts: r.ts, file: r.file, code: r.rejection_code, by: r.updated_by })) });
} else {
add("refused-writes-none", "ok", "No memory writes refused in the last 24h.");
}
} catch { /* non-fatal */ }
}

// F5: evidence-driven Cortex health. Paths and clock are injectable so tests never touch live state.
function cortexThresholdEnv(name: string): number | undefined {
const raw = process.env[name]; if (raw === undefined || raw === "") return undefined;
Expand Down
37 changes: 28 additions & 9 deletions LifeOS/install/LIFEOS/TOOLS/MemoryReviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,18 +326,25 @@ A confident "nothing to save" is correct.`;
export interface CurrentMemorySnapshot {
principal: string[];
assistant: string[];
/** Fingerprint of each list as read; absent when the file could not be read. */
fingerprints?: { principal?: string; assistant?: string };
}

const PRINCIPAL_MEMORY_PATH = pathResolve(CLAUDE_ROOT, "LIFEOS/USER/PRINCIPAL/PRINCIPAL_MEMORY.md");
const DA_MEMORY_PATH = pathResolve(CLAUDE_ROOT, "LIFEOS/USER/DIGITAL_ASSISTANT/DA_MEMORY.md");

/** Read both hot-layer files' current entries so the reviewer curates against reality. */
export function readCurrentMemorySnapshot(): CurrentMemorySnapshot {
const readEntries = (path: string): string[] => {
const r = memoryWriterRead(path);
return "code" in r ? [] : r.entries;
const principal = memoryWriterRead(PRINCIPAL_MEMORY_PATH);
const assistant = memoryWriterRead(DA_MEMORY_PATH);
return {
principal: "code" in principal ? [] : principal.entries,
assistant: "code" in assistant ? [] : assistant.entries,
fingerprints: {
principal: "code" in principal ? undefined : principal.fingerprint,
assistant: "code" in assistant ? undefined : assistant.fingerprint,
},
};
return { principal: readEntries(PRINCIPAL_MEMORY_PATH), assistant: readEntries(DA_MEMORY_PATH) };
}

function renderCurrentMemory(snap: CurrentMemorySnapshot | undefined): string[] {
Expand Down Expand Up @@ -493,10 +500,13 @@ export interface DispatchSummary {

/** A deliberate MemoryWriter safety refusal (net-drop erosion block), not a pipeline fault. */
function isGuardRefusal(result: AddResult): boolean {
return !result.ok && /ESUSPECT_EROSION/.test((result as { message?: string }).message ?? "");
return !result.ok && /ESUSPECT_EROSION|ESTALE_BASE/.test((result as { message?: string }).message ?? "");
}

export function dispatchItems(items: TypedItem[], opts: { dryRun?: boolean; confidenceThreshold?: number } = {}): { summary: DispatchSummary; results: AddResult[] } {
export function dispatchItems(
items: TypedItem[],
opts: { dryRun?: boolean; confidenceThreshold?: number; baseFingerprints?: CurrentMemorySnapshot["fingerprints"] } = {},
): { summary: DispatchSummary; results: AddResult[] } {
const summary: DispatchSummary = {
total: items.length,
by_type: {},
Expand All @@ -510,6 +520,7 @@ export function dispatchItems(items: TypedItem[], opts: { dryRun?: boolean; conf
};
const results: AddResult[] = [];
const threshold = opts.confidenceThreshold ?? loadConfidenceThreshold();
const bases: NonNullable<CurrentMemorySnapshot["fingerprints"]> = { ...opts.baseFingerprints };

for (let i = 0; i < items.length; i++) {
const item = items[i];
Expand All @@ -521,8 +532,15 @@ export function dispatchItems(items: TypedItem[], opts: { dryRun?: boolean; conf
continue;
}

const result = memoryAdd(item);
// Memory sets replace the whole file with a list computed from the snapshot
// taken before inference; the fingerprint makes the write refuse if the file
// changed in the meantime. It comes from our own read, never from the model.
const result = memoryAdd(item, item.type === "memory" ? { expectedFingerprint: bases[item.actor] } : {});
results.push(result);
// A second item for the same actor builds on this write, not the old snapshot.
if (item.type === "memory" && result.ok && typeof result.detail?.fingerprint === "string") {
bases[item.actor] = result.detail.fingerprint;
}
if (result.ok) {
summary.succeeded++;

Expand Down Expand Up @@ -575,7 +593,8 @@ export function dispatchItems(items: TypedItem[], opts: { dryRun?: boolean; conf
}
} else if (isGuardRefusal(result)) {
// A safety-guard refusal (ESUSPECT_EROSION — the MemoryWriter blocking a net-drop
// consolidation) is the guard working, not a pipeline failure. Counting it as `failed`
// consolidation; ESTALE_BASE — the file changed during inference, so a newer edit
// won; MemoryHealthCheck reports those separately) is the guard working, not a pipeline failure. Counting it as `failed`
// flips the run ok=false and trips the memory-health CRITICAL alert on a correct refusal
// (same class as the empty-transcript→skipped precedent). Surface it as a skip: the fuller
// memory set the guard preserved is intact, and retrying with allowDrastic is a human call.
Expand Down Expand Up @@ -731,7 +750,7 @@ export async function review(opts: ReviewOptions = {}): Promise<ReviewResult> {
writeRunDebug(runId, { "response.parsed.json": JSON.stringify(parsed.output, null, 2) });

// 6. Dispatch
const { summary, results } = dispatchItems(parsed.output.items, { dryRun: opts.dryRun });
const { summary, results } = dispatchItems(parsed.output.items, { dryRun: opts.dryRun, baseFingerprints: snapshot.fingerprints });
writeRunDebug(runId, {
"dispatch.log": [
`Items: ${summary.total} (succeeded=${summary.succeeded} failed=${summary.failed} skipped_guard=${summary.skipped_guard})`,
Expand Down
Loading