Skip to content

Commit 879e5ef

Browse files
Merge pull request #105 from CodeWithJuber/claude/loop-goal-worktree-issues-il3uk9
fix(memory): make reconcileFacts safe under FORGE_LEDGER_ONLY (M2, partial)
2 parents a0ee4ea + 190243b commit 879e5ef

3 files changed

Lines changed: 48 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- **`reconcileFacts` no longer risks wiping memory under `FORGE_LEDGER_ONLY` (M2).** The
12+
reconcile heuristic tombstones any author-owned fact claim with no backing file; under
13+
ledger-only there are no fact files, so it would have tombstoned every fact. It is now a
14+
guarded no-op when ledger-only is active (the ledger IS the store then — there is nothing
15+
to reconcile against). Covered by a new no-data-loss regression test.
16+
917
## [0.26.1] - 2026-07-20
1018

1119
### Changed

‎src/ledger_bridge.js‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { load as loadLessons } from "./lessons_store.js";
2323
// merged `list` (P2 read flip) includes ledger-only teammate facts, which have no file
2424
// and would read here as "deleted from the store".
2525
import { listStored as listFacts, readFact } from "./recall.js";
26-
import { epochDay, gitAuthor } from "./util.js";
26+
import { epochDay, gitAuthor, ledgerOnly } from "./util.js";
2727

2828
/** One best-effort policy for the whole bridge (never throws into a caller). */
2929
const bestEffort = (fn) => {
@@ -97,7 +97,11 @@ export function recordLessonEvent(root, lesson, ev = {}) {
9797
author: gitAuthor(),
9898
t: ev.t ?? 0,
9999
});
100-
if (!o.ok) return { ok: false, reason: "reason" in o ? o.reason : "invalid outcome" };
100+
if (!o.ok)
101+
return {
102+
ok: false,
103+
reason: "reason" in o ? o.reason : "invalid outcome",
104+
};
101105
const a = appendEvidence(dir, minted.claim.id, o.outcome);
102106
if (!a.ok) return a;
103107
}
@@ -169,6 +173,10 @@ export function shadowFact(ledgerDir, name, text, t = epochDay()) {
169173
*/
170174
export function reconcileFacts(store, ledgerDir, t = epochDay()) {
171175
return bestEffort(() => {
176+
// Ledger-only: there are no fact FILES to reconcile against — the ledger IS the
177+
// store, so "no backing file ⇒ tombstone" is inverted and would wipe every fact.
178+
// Reconciliation only makes sense while the file store is canonical (default off).
179+
if (ledgerOnly()) return { ok: true, removed: 0 };
172180
const current = new Set();
173181
for (const slug of listFacts(store)) {
174182
const f = readFact(store, slug);
@@ -186,7 +194,11 @@ export function reconcileFacts(store, ledgerDir, t = epochDay()) {
186194
// silently delete team knowledge on every consolidate.
187195
const mine = (c.provenance?.author ?? "") === gitAuthor();
188196
if (c.kind === "fact" && !c.tombstone && mine && !current.has(c.id)) {
189-
tombstone(ledgerDir, c.id, { author: gitAuthor(), reason: "removed-from-store", t });
197+
tombstone(ledgerDir, c.id, {
198+
author: gitAuthor(),
199+
reason: "removed-from-store",
200+
t,
201+
});
190202
removed++;
191203
}
192204
}

‎test/ledger_only.test.js‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { existsSync, mkdtempSync } from "node:fs";
77
import { tmpdir } from "node:os";
88
import { join } from "node:path";
99
import { test } from "node:test";
10-
import { recordLessonEvent, shadowFact } from "../src/ledger_bridge.js";
11-
import { ledgerLessons, mergedLessons } from "../src/ledger_read.js";
10+
import { reconcileFacts, recordLessonEvent, shadowFact } from "../src/ledger_bridge.js";
11+
import { ledgerFacts, ledgerLessons, mergedLessons } from "../src/ledger_read.js";
1212
import { newLesson } from "../src/lessons.js";
1313
import { lessonsDir, save } from "../src/lessons_store.js";
1414
import { add, list, readFact } from "../src/recall.js";
@@ -36,7 +36,12 @@ const makeLesson = (id) =>
3636
newLesson(
3737
{
3838
id,
39-
trigger: { symbols: ["verifyToken"], files: [], keywords: [], action: "edit" },
39+
trigger: {
40+
symbols: ["verifyToken"],
41+
files: [],
42+
keywords: [],
43+
action: "edit",
44+
},
4045
scope: "symbol",
4146
whatWentWrong: "forgot to check expiry",
4247
correctedBehavior: "assert exp before trusting the token",
@@ -90,3 +95,20 @@ test("ledger-only: recall.add writes no fact file; readFact + list resolve from
9095
assert.ok(list(store).includes(res.slug), "merged list includes the ledger fact");
9196
});
9297
});
98+
99+
test("ledger-only: reconcileFacts is a no-op and never tombstones ledger facts (no data loss)", () => {
100+
withEnv({ FORGE_LEDGER_ONLY: "1", FORGE_AUTHOR: "Tester <t@example.com>" }, () => {
101+
const store = tmpRoot();
102+
const ledgerDir = join(store, "ledger");
103+
// Two facts live only in the ledger (no fact files exist under ledger-only).
104+
shadowFact(ledgerDir, "API base", "https://api.example.com", 0);
105+
shadowFact(ledgerDir, "DB host", "db.example.com", 0);
106+
assert.equal(ledgerFacts(ledgerDir).length, 2, "two live facts before reconcile");
107+
// Under the file-store model this would tombstone BOTH (no backing file); under
108+
// ledger-only it must leave them untouched.
109+
const r = reconcileFacts(store, ledgerDir, 1);
110+
assert.equal(r.ok, true);
111+
assert.equal(r.removed, 0, "reconcile removes nothing under ledger-only");
112+
assert.equal(ledgerFacts(ledgerDir).length, 2, "both facts survive — no memory wiped");
113+
});
114+
});

0 commit comments

Comments
 (0)