Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/ledger_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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++;
}
}
Expand Down
28 changes: 25 additions & 3 deletions test/ledger_only.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <[email protected]>" }, () => {
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");
});
});
Loading