From 190243b7cc43792d9486eece2e8b2887661dbcdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:25:35 +0000 Subject: [PATCH] fix(memory): make reconcileFacts safe under FORGE_LEDGER_ONLY (M2 stage 1) reconcileFacts tombstones any author-owned fact claim that has no backing file in the store. Under ledger-only there are no fact files, so it would tombstone EVERY fact -- silently wiping memory. It is now a guarded no-op when ledger-only is active: the ledger is the store then, so there is nothing to reconcile against. This is the destructive-heuristic fix that has to land BEFORE the default can be flipped. Adds a no-data-loss regression test (two ledger-only facts survive a reconcile). Full suite 1070 pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C7htTiesXKLvAUtv6ET2jz --- CHANGELOG.md | 8 ++++++++ src/ledger_bridge.js | 18 +++++++++++++++--- test/ledger_only.test.js | 28 +++++++++++++++++++++++++--- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fa9a9..c374606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **`reconcileFacts` no longer risks wiping memory under `FORGE_LEDGER_ONLY` (M2).** The + reconcile heuristic tombstones any author-owned fact claim with no backing file; under + ledger-only there are no fact files, so it would have tombstoned every fact. It is now a + guarded no-op when ledger-only is active (the ledger IS the store then — there is nothing + to reconcile against). Covered by a new no-data-loss regression test. + ## [0.26.1] - 2026-07-20 ### Changed diff --git a/src/ledger_bridge.js b/src/ledger_bridge.js index b8144ef..5650745 100644 --- a/src/ledger_bridge.js +++ b/src/ledger_bridge.js @@ -23,7 +23,7 @@ import { load as loadLessons } from "./lessons_store.js"; // merged `list` (P2 read flip) includes ledger-only teammate facts, which have no file // and would read here as "deleted from the store". import { listStored as listFacts, readFact } from "./recall.js"; -import { epochDay, gitAuthor } from "./util.js"; +import { epochDay, gitAuthor, ledgerOnly } from "./util.js"; /** One best-effort policy for the whole bridge (never throws into a caller). */ const bestEffort = (fn) => { @@ -97,7 +97,11 @@ export function recordLessonEvent(root, lesson, ev = {}) { author: gitAuthor(), t: ev.t ?? 0, }); - if (!o.ok) return { ok: false, reason: "reason" in o ? o.reason : "invalid outcome" }; + if (!o.ok) + return { + ok: false, + reason: "reason" in o ? o.reason : "invalid outcome", + }; const a = appendEvidence(dir, minted.claim.id, o.outcome); if (!a.ok) return a; } @@ -169,6 +173,10 @@ export function shadowFact(ledgerDir, name, text, t = epochDay()) { */ export function reconcileFacts(store, ledgerDir, t = epochDay()) { return bestEffort(() => { + // Ledger-only: there are no fact FILES to reconcile against — the ledger IS the + // store, so "no backing file ⇒ tombstone" is inverted and would wipe every fact. + // Reconciliation only makes sense while the file store is canonical (default off). + if (ledgerOnly()) return { ok: true, removed: 0 }; const current = new Set(); for (const slug of listFacts(store)) { const f = readFact(store, slug); @@ -186,7 +194,11 @@ export function reconcileFacts(store, ledgerDir, t = epochDay()) { // silently delete team knowledge on every consolidate. const mine = (c.provenance?.author ?? "") === gitAuthor(); if (c.kind === "fact" && !c.tombstone && mine && !current.has(c.id)) { - tombstone(ledgerDir, c.id, { author: gitAuthor(), reason: "removed-from-store", t }); + tombstone(ledgerDir, c.id, { + author: gitAuthor(), + reason: "removed-from-store", + t, + }); removed++; } } diff --git a/test/ledger_only.test.js b/test/ledger_only.test.js index a597f3d..3f83932 100644 --- a/test/ledger_only.test.js +++ b/test/ledger_only.test.js @@ -7,8 +7,8 @@ import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { recordLessonEvent, shadowFact } from "../src/ledger_bridge.js"; -import { ledgerLessons, mergedLessons } from "../src/ledger_read.js"; +import { reconcileFacts, recordLessonEvent, shadowFact } from "../src/ledger_bridge.js"; +import { ledgerFacts, ledgerLessons, mergedLessons } from "../src/ledger_read.js"; import { newLesson } from "../src/lessons.js"; import { lessonsDir, save } from "../src/lessons_store.js"; import { add, list, readFact } from "../src/recall.js"; @@ -36,7 +36,12 @@ const makeLesson = (id) => newLesson( { id, - trigger: { symbols: ["verifyToken"], files: [], keywords: [], action: "edit" }, + trigger: { + symbols: ["verifyToken"], + files: [], + keywords: [], + action: "edit", + }, scope: "symbol", whatWentWrong: "forgot to check expiry", correctedBehavior: "assert exp before trusting the token", @@ -90,3 +95,20 @@ test("ledger-only: recall.add writes no fact file; readFact + list resolve from assert.ok(list(store).includes(res.slug), "merged list includes the ledger fact"); }); }); + +test("ledger-only: reconcileFacts is a no-op and never tombstones ledger facts (no data loss)", () => { + withEnv({ FORGE_LEDGER_ONLY: "1", FORGE_AUTHOR: "Tester " }, () => { + const store = tmpRoot(); + const ledgerDir = join(store, "ledger"); + // Two facts live only in the ledger (no fact files exist under ledger-only). + shadowFact(ledgerDir, "API base", "https://api.example.com", 0); + shadowFact(ledgerDir, "DB host", "db.example.com", 0); + assert.equal(ledgerFacts(ledgerDir).length, 2, "two live facts before reconcile"); + // Under the file-store model this would tombstone BOTH (no backing file); under + // ledger-only it must leave them untouched. + const r = reconcileFacts(store, ledgerDir, 1); + assert.equal(r.ok, true); + assert.equal(r.removed, 0, "reconcile removes nothing under ledger-only"); + assert.equal(ledgerFacts(ledgerDir).length, 2, "both facts survive — no memory wiped"); + }); +});