From a104f98dcaea6705122246280171009b118417e8 Mon Sep 17 00:00:00 2001 From: Masa <98894508+Masashi-Ono0611@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:44:17 +0900 Subject: [PATCH] fix(cycle): extract_facts guard counts only active legacy rows; reconcile preserves forget records (#2646) (#3474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master's empty-fence guard counts soft-expired legacy rows (row_num IS NULL, expired_at set), so forget_fact — the sanctioned removal path, which soft-expires rather than deletes — can never drain the backlog: apply-migrations no-ops (already marked applied) and the guard stays triggered forever, jamming extract_facts. Narrowed re-send of #3252-sibling #3234, scoped to exactly what the maintainer named reviewable: "one predicate plus the reconcile-preservation guard." - Guard predicate: the legacy COUNT adds `AND f.expired_at IS NULL`, so each forget_fact visibly drains the pending counter. - Reconcile preservation: listExistingFactsForPage excludes soft-expired legacy rows (they are never fence-owned, so they must neither read as perpetually stale nor mask a fence row), and the two wipe call sites pass `preserveExpiredLegacy: true` so deleteFactsForPage keeps the forget record. The option is the minimal seam for that guard — implemented identically in both engines (~6 lines each). Explicitly NOT included from #3234 (per the close review): the drift-repair lane, the re-runnable migration orchestrator path, and the race-accounting layer. Fence-is-canonical semantics are preserved and pinned by test: if the fence still carries an expired legacy row's claim, the reconcile reinserts it as a fresh active fence-owned row (legacy DB-only forgets are documented non-durable; the expired row survives as the audit record of the forget). Co-authored-by: Time Attakc <89218912+time-attack@users.noreply.github.com> --- src/core/cycle/extract-facts.ts | 62 ++++-- src/core/engine.ts | 13 +- src/core/pglite-engine.ts | 12 +- src/core/postgres-engine.ts | 11 +- .../facts-fence-reconcile-postgres.test.ts | 87 +++++++++ test/extract-facts-phase.test.ts | 180 ++++++++++++++++++ 6 files changed, 345 insertions(+), 20 deletions(-) diff --git a/src/core/cycle/extract-facts.ts b/src/core/cycle/extract-facts.ts index 04ee53cea..94995539a 100644 --- a/src/core/cycle/extract-facts.ts +++ b/src/core/cycle/extract-facts.ts @@ -23,11 +23,12 @@ * page coordinate only; legacy NULL-source_markdown_slug rows survive * because deleteFactsForPage targets source_markdown_slug = slug only. * - * Empty-fence guard (Codex R2-#7; #2484): the phase refuses to do its - * destructive reconciliation pass when genuinely-backfillable legacy + * Empty-fence guard (Codex R2-#7; #2484; #2646): the phase refuses to do + * its destructive reconciliation pass when genuinely-backfillable legacy * rows still exist — `row_num IS NULL` (never fenced) AND `entity_slug` * resolves to a live page in this source (so the v0_32_2 migration's - * Phase B could fence them). Status returns `warn` with a hint to run + * Phase B could fence them) AND the row is not soft-expired + * (`expired_at IS NULL`). Status returns `warn` with a hint to run * `gbrain apply-migrations --yes`. Without the guard, an interrupted * upgrade where v0_32_2 hasn't run could leave the cycle silently * misreporting "0 facts on people/alice" while legacy rows linger. @@ -41,6 +42,10 @@ * the phase jams forever (~16/day observed). Requiring a backing page * keeps genuine pre-v0.32.2 rows (whose entity page exists) gating * while excluding the inline-writer's permanent-unfenceable rows. + * + * Soft-expired rows don't count either (#2646): they're what + * `forget_fact` produces, so excluding them lets operators drain the + * backlog through the sanctioned removal path instead of raw SQL. */ import type { BrainEngine } from '../engine.ts'; @@ -88,6 +93,24 @@ function dedupeFactsByContentKey(facts: FenceExtractedFact[]): FenceExtractedFac * neither count as "stale" (which would force a wipe every cycle) nor * be compared against the fence's row set. Mirrors the * excludeSourcePrefixes filter deleteFactsForPage applies on the wipe. + * + * Also excludes soft-expired legacy rows (#2646: `row_num IS NULL AND + * expired_at IS NOT NULL`) — rows that `forget_fact` expired via its + * legacy DB-only path. They are not fence-owned (fence rows always + * carry a row_num), so they must neither count as "stale" (forcing a + * wipe every cycle) nor mask a fence row from insertion. Mirrors the + * preserveExpiredLegacy filter deleteFactsForPage applies on the wipe. + * + * Deliberate consequence: if the fence still carries the same + * (claim, source) as an expired legacy row, the reconcile inserts it + * as a fresh ACTIVE fence-owned row. That is the fence-is-canonical + * contract working as documented — legacy DB-only forgets "DO NOT + * survive rebuild" (see forget.ts header); suppressing the insert + * would instead create silent fence↔DB divergence, the exact failure + * mode the empty-fence guard exists to prevent. To durably forget + * such a claim, forget the fence-owned row (forget_fact now takes the + * fence path, which strikes the row through in markdown). The expired + * legacy row survives alongside as the record of the earlier forget. */ async function listExistingFactsForPage( engine: BrainEngine, @@ -100,6 +123,7 @@ async function listExistingFactsForPage( WHERE source_id = $1 AND source_markdown_slug = $2 AND COALESCE(source, '') NOT LIKE 'cli:%' + AND NOT (row_num IS NULL AND expired_at IS NOT NULL) ORDER BY row_num ASC, id ASC`, [sourceId, slug], ); @@ -173,7 +197,7 @@ export async function runExtractFacts( phantomsMorePending: false, }; - // ── Empty-fence guard (Codex R2-#7; #2484) ───────────────────── + // ── Empty-fence guard (Codex R2-#7; #2484; #2646) ────────────── // Pre-check: if any genuinely-backfillable legacy fact rows exist, // refuse to run the destructive reconciliation pass — the v0_32_2 // orchestrator must fence them first. @@ -181,12 +205,13 @@ export async function runExtractFacts( // A row is a real backfill candidate only when `row_num IS NULL` // (never fenced) AND its `entity_slug` resolves to a LIVE page in // this source (the migration's Phase B only fences rows whose - // entity_slug maps to a writable page). #2484: the original - // predicate was just `row_num IS NULL AND entity_slug IS NOT NULL`, - // which ALSO matched structurally-unfenceable hot-memory rows the - // inline writer keeps producing post-migration: the legacy DB-only - // fallback (backstop.ts) writes `entity_slug` (a resolved slug, e.g. - // a slugify-floor or stub-guard-blocked unprefixed slug like + // entity_slug maps to a writable page) AND it is not soft-expired. + // #2484: the original predicate was just `row_num IS NULL AND + // entity_slug IS NOT NULL`, which ALSO matched + // structurally-unfenceable hot-memory rows the inline writer keeps + // producing post-migration: the legacy DB-only fallback + // (backstop.ts) writes `entity_slug` (a resolved slug, e.g. a + // slugify-floor or stub-guard-blocked unprefixed slug like // `people-jane-doe`) with `row_num` NULL whenever the slug has no // fenceable page. Those rows can never satisfy the migration's exit // condition (no page to fence onto, and `apply-migrations` is a @@ -194,12 +219,17 @@ export async function runExtractFacts( // — ~16/day, mislabeled "v0.31 pending backfill." We now require a // live backing page, which both genuine pre-v0.32.2 rows (their // entity page exists) satisfy and inline-writer unfenceable rows do - // not. + // not. #2646: soft-expired rows (`expired_at IS NOT NULL`) are also + // excluded — `forget_fact`, the officially sanctioned removal path, + // soft-expires legacy rows rather than deleting them, so counting + // expired rows would leave the guard permanently stuck with no + // supported way to drain the backlog. const legacy = await engine.executeRaw<{ n: string }>( `SELECT COUNT(*) AS n FROM facts f WHERE f.row_num IS NULL AND f.entity_slug IS NOT NULL + AND f.expired_at IS NULL AND EXISTS ( SELECT 1 FROM pages p WHERE p.source_id = f.source_id @@ -334,9 +364,12 @@ export async function runExtractFacts( // partial-UNIQUE-index keyspace). #1928: `cli:`-origin facts // (conversation facts from extract-conversation-facts) are NOT // fence-owned — the page carries no `## Facts` fence to recreate - // them — so they MUST survive this reconcile. + // them — so they MUST survive this reconcile. #2646: soft-expired + // legacy rows (forget_fact's record of the forget) likewise + // survive via preserveExpiredLegacy. const deleted = await engine.deleteFactsForPage(slug, sourceId, { excludeSourcePrefixes: ['cli:'], + preserveExpiredLegacy: true, }); result.factsDeleted += deleted.deleted; } @@ -363,10 +396,11 @@ export async function runExtractFacts( if (hasStaleExisting || hasDuplicateExisting || hasRowNumDrift) { // Fall back to the legacy page-level reconcile when old DB rows must // be removed. Same delete scoping as above: legacy - // NULL-source_markdown_slug rows and `cli:`-origin conversation - // facts (#1928) survive. + // NULL-source_markdown_slug rows, `cli:`-origin conversation + // facts (#1928), and soft-expired legacy rows (#2646) survive. const deleted = await engine.deleteFactsForPage(slug, sourceId, { excludeSourcePrefixes: ['cli:'], + preserveExpiredLegacy: true, }); result.factsDeleted += deleted.deleted; toInsert = extracted; diff --git a/src/core/engine.ts b/src/core/engine.ts index e7c7b62a7..871e031b0 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -1815,11 +1815,22 @@ export interface BrainEngine { * never recreate them (the page has no `## Facts` fence). Omitted ⇒ legacy * behavior (delete every fact on the page coordinate). NULL/empty `source` * rows are always deletable (fence default). + * + * #2646: `preserveExpiredLegacy` protects soft-expired legacy rows + * (`row_num IS NULL AND expired_at IS NOT NULL`) — the record left by + * `forget_fact`'s legacy DB-only path. Fence rows always carry a + * `row_num`, so these rows are never fence-owned and a wipe would + * destroy the forget record (the audit trail of the forget). Note what + * this does NOT promise: it protects the record, not the forget itself — + * if the fence still carries the same claim, fence canonicality + * independently reinserts it as a fresh active row (legacy DB-only + * forgets are documented as non-durable; see extract-facts.ts). Omitted + * ⇒ legacy behavior. */ deleteFactsForPage( slug: string, source_id: string, - opts?: { excludeSourcePrefixes?: string[] }, + opts?: { excludeSourcePrefixes?: string[]; preserveExpiredLegacy?: boolean }, ): Promise<{ deleted: number }>; /** diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index f3a4f924c..ab562db23 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -4281,9 +4281,15 @@ export class PGLiteEngine implements BrainEngine { async deleteFactsForPage( slug: string, source_id: string, - opts?: { excludeSourcePrefixes?: string[] }, + opts?: { excludeSourcePrefixes?: string[]; preserveExpiredLegacy?: boolean }, ): Promise<{ deleted: number }> { const prefixes = opts?.excludeSourcePrefixes; + // #2646: keep soft-expired legacy rows (row_num NULL — never + // fence-owned) so a fence reconcile can't destroy forget_fact's + // legacy DB-only forget record. + const expiredLegacyFilter = opts?.preserveExpiredLegacy + ? ` AND NOT (row_num IS NULL AND expired_at IS NOT NULL)` + : ''; if (prefixes && prefixes.length > 0) { // #1928: keep rows whose `source` matches an excluded prefix (e.g. // `cli:` conversation facts). COALESCE so NULL/empty-source fence rows @@ -4292,13 +4298,13 @@ export class PGLiteEngine implements BrainEngine { const result = await this.db.query( `DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug = $2 - AND NOT (COALESCE(source, '') LIKE ANY($3::text[]))`, + AND NOT (COALESCE(source, '') LIKE ANY($3::text[]))${expiredLegacyFilter}`, [source_id, slug, patterns], ); return { deleted: result.affectedRows ?? 0 }; } const result = await this.db.query( - `DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug = $2`, + `DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug = $2${expiredLegacyFilter}`, [source_id, slug], ); return { deleted: result.affectedRows ?? 0 }; diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 11ba60c9a..07608f3a4 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -4438,10 +4438,16 @@ export class PostgresEngine implements BrainEngine { async deleteFactsForPage( slug: string, source_id: string, - opts?: { excludeSourcePrefixes?: string[] }, + opts?: { excludeSourcePrefixes?: string[]; preserveExpiredLegacy?: boolean }, ): Promise<{ deleted: number }> { const sql = this.sql; const prefixes = opts?.excludeSourcePrefixes; + // #2646: keep soft-expired legacy rows (row_num NULL — never + // fence-owned) so a fence reconcile can't destroy forget_fact's + // legacy DB-only forget record. + const expiredLegacyFilter = opts?.preserveExpiredLegacy + ? sql`AND NOT (row_num IS NULL AND expired_at IS NOT NULL)` + : sql``; if (prefixes && prefixes.length > 0) { // #1928: keep rows whose `source` matches an excluded prefix (e.g. // `cli:` conversation facts). COALESCE so NULL/empty-source fence rows @@ -4452,11 +4458,12 @@ export class PostgresEngine implements BrainEngine { WHERE source_id = ${source_id} AND source_markdown_slug = ${slug} AND NOT (COALESCE(source, '') LIKE ANY(${patterns})) + ${expiredLegacyFilter} `; return { deleted: result.count ?? 0 }; } const result = await sql` - DELETE FROM facts WHERE source_id = ${source_id} AND source_markdown_slug = ${slug} + DELETE FROM facts WHERE source_id = ${source_id} AND source_markdown_slug = ${slug} ${expiredLegacyFilter} `; return { deleted: result.count ?? 0 }; } diff --git a/test/e2e/facts-fence-reconcile-postgres.test.ts b/test/e2e/facts-fence-reconcile-postgres.test.ts index 774b55f44..d57e80620 100644 --- a/test/e2e/facts-fence-reconcile-postgres.test.ts +++ b/test/e2e/facts-fence-reconcile-postgres.test.ts @@ -73,3 +73,90 @@ describe.skipIf(skip)('facts-fence escaped-pipe reconciliation on Postgres', () ]); }, 30_000); }); + +describe.skipIf(skip)('deleteFactsForPage preserveExpiredLegacy on Postgres (#2646)', () => { + // The PGLite side of this contract is pinned by + // test/extract-facts-phase.test.ts; this pins the postgres.js + // tagged-fragment SQL (the two branches interpolate `expiredLegacyFilter` + // differently) AND the returned delete count on a real Postgres. + const slug = 'people/expired-legacy-preserve-example'; + let engine: PostgresEngine; + + beforeAll(async () => { + engine = new PostgresEngine(); + await engine.connect({ database_url: databaseUrl! }); + await engine.initSchema(); + }); + + afterAll(async () => { + if (engine) { + await engine.executeRaw('DELETE FROM facts WHERE source_markdown_slug = $1', [slug]); + await engine.executeRaw('DELETE FROM pages WHERE slug = $1', [slug]); + await engine.disconnect(); + } + }); + + async function seedRows(): Promise { + await engine.executeRaw('DELETE FROM facts WHERE source_markdown_slug = $1', [slug]); + // One fence-owned active row (deletable) + one soft-expired legacy row + // (row_num NULL, expired_at set — forget_fact's record, must survive). + await engine.executeRaw( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, row_num, expired_at, source_markdown_slug) + VALUES + ('default', $1, 'fence-owned active fact', 'fact', 'world', 'high', + now(), 'fence:reconcile', 1.0, 1, NULL, $1), + ('default', $1, 'forgotten legacy claim', 'fact', 'private', 'medium', + now(), 'mcp:put_page', 1.0, NULL, now(), $1)`, + [slug], + ); + } + + test('no-prefix branch: expired legacy row survives, count reflects only real deletions', async () => { + await seedRows(); + const { deleted } = await engine.deleteFactsForPage(slug, 'default', { + preserveExpiredLegacy: true, + }); + expect(deleted).toBe(1); // only the fence-owned row + + const rows = await engine.executeRaw<{ fact: string }>( + 'SELECT fact FROM facts WHERE source_markdown_slug = $1', [slug], + ); + expect(Array.from(rows).map(r => r.fact)).toEqual(['forgotten legacy claim']); + }, 30_000); + + test('prefix branch: excludeSourcePrefixes and preserveExpiredLegacy compose', async () => { + await seedRows(); + // Add a cli:-origin row that the prefix exclusion must protect. + await engine.executeRaw( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, row_num, expired_at, source_markdown_slug) + VALUES ('default', $1, 'conversation fact', 'fact', 'private', 'medium', + now(), 'cli:extract-conversation-facts', 1.0, NULL, NULL, $1)`, + [slug], + ); + const { deleted } = await engine.deleteFactsForPage(slug, 'default', { + excludeSourcePrefixes: ['cli:'], + preserveExpiredLegacy: true, + }); + expect(deleted).toBe(1); // only the fence-owned row + + const rows = await engine.executeRaw<{ fact: string }>( + 'SELECT fact FROM facts WHERE source_markdown_slug = $1 ORDER BY id', [slug], + ); + expect(Array.from(rows).map(r => r.fact)).toEqual([ + 'forgotten legacy claim', + 'conversation fact', + ]); + }, 30_000); + + test('omitted option keeps legacy wipe behavior (expired row IS deleted)', async () => { + await seedRows(); + const { deleted } = await engine.deleteFactsForPage(slug, 'default'); + expect(deleted).toBe(2); + const rows = await engine.executeRaw<{ fact: string }>( + 'SELECT fact FROM facts WHERE source_markdown_slug = $1', [slug], + ); + expect(Array.from(rows)).toHaveLength(0); + }, 30_000); +}); diff --git a/test/extract-facts-phase.test.ts b/test/extract-facts-phase.test.ts index 5f046dfdf..d45900ee1 100644 --- a/test/extract-facts-phase.test.ts +++ b/test/extract-facts-phase.test.ts @@ -334,6 +334,186 @@ describe('runExtractFacts — empty-fence guard (Codex R2-#7)', () => { expect(r.factsInserted).toBe(1); }); + test('soft-expired legacy rows do NOT trigger the guard (#2646 — forget_fact drains the backlog)', async () => { + // A legacy row that forget_fact already soft-expired. Before #2646 + // the guard counted it forever: apply-migrations no-ops (migration + // marked applied) and forget_fact only sets expired_at, so the + // phase was permanently blocked with no sanctioned way out. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, expired_at) + VALUES ('default', 'people/alice', 'forgotten legacy claim', 'fact', 'private', 'medium', + now(), 'mcp:put_page', 1.0, now())`, + ); + + await putPage('people/alice', FACT_FENCE( + `| 1 | new fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`, + )); + + const r = await runExtractFacts(engine, { slugs: ['people/alice'] }); + + expect(r.guardTriggered).toBe(false); + expect(r.legacyRowsPending).toBe(0); + expect(r.factsInserted).toBe(1); + + // The expired legacy row itself is untouched (soft-expire is the + // record of the forget; the phase must not hard-delete it). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + `SELECT fact FROM facts WHERE row_num IS NULL AND expired_at IS NOT NULL`, + ); + expect(rows.rows).toHaveLength(1); + expect(rows.rows[0].fact).toBe('forgotten legacy claim'); + }); + + test('expired legacy row WITH source_markdown_slug set survives reconcile untouched (#2646 codex P2)', async () => { + // Hybrid shape: row_num NULL (legacy — never fence-owned) but + // source_markdown_slug matching a live page. Without the + // preserveExpiredLegacy filter, the reconcile pass would count it + // as "stale", trigger a wipe, hard-delete the forget record, and + // reinsert the fence's rows fresh — reviving a forgotten claim as + // an active fact. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, expired_at, source_markdown_slug) + VALUES ('default', 'people/alice', 'forgotten hybrid claim', 'fact', 'private', 'medium', + now(), 'mcp:put_page', 1.0, now(), 'people/alice')`, + ); + + await putPage('people/alice', FACT_FENCE( + `| 1 | fence fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`, + )); + + const r1 = await runExtractFacts(engine, { slugs: ['people/alice'] }); + const r2 = await runExtractFacts(engine, { slugs: ['people/alice'] }); + + expect(r1.guardTriggered).toBe(false); + // The expired hybrid row is invisible to the reconcile: the fence + // fact inserts normally, nothing is wiped, and re-running stays + // idempotent (the hybrid row must not read as perpetually stale). + expect(r1.factsInserted).toBe(1); + expect(r1.factsDeleted).toBe(0); + expect(r2.factsInserted).toBe(0); + expect(r2.factsDeleted).toBe(0); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + `SELECT fact, expired_at FROM facts WHERE source_markdown_slug = 'people/alice' ORDER BY id`, + ); + expect(rows.rows).toHaveLength(2); + expect(rows.rows[0].fact).toBe('forgotten hybrid claim'); + expect(rows.rows[0].expired_at).not.toBeNull(); + expect(rows.rows[1].fact).toBe('fence fact'); + expect(rows.rows[1].expired_at).toBeNull(); + }); + + test('expired legacy hybrid row survives even a stale-row wipe on the same page (#2646 codex P2)', async () => { + // Force the wipe path: seed a fence, reconcile, then change the + // fence so the old DB row goes stale. The wipe must delete the + // stale fence-owned row but preserve the expired legacy hybrid. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, expired_at, source_markdown_slug) + VALUES ('default', 'people/alice', 'forgotten hybrid claim', 'fact', 'private', 'medium', + now(), 'mcp:put_page', 1.0, now(), 'people/alice')`, + ); + + await putPage('people/alice', FACT_FENCE( + `| 1 | old fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`, + )); + await runExtractFacts(engine, { slugs: ['people/alice'] }); + + // Replace the fence content — 'old fact' is now stale in the DB. + await putPage('people/alice', FACT_FENCE( + `| 1 | replacement fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`, + )); + const r = await runExtractFacts(engine, { slugs: ['people/alice'] }); + + expect(r.factsDeleted).toBe(1); // only the stale fence-owned row + expect(r.factsInserted).toBe(1); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + `SELECT fact FROM facts WHERE source_markdown_slug = 'people/alice' ORDER BY id`, + ); + expect(rows.rows.map((row: { fact: string }) => row.fact)) + .toEqual(['forgotten hybrid claim', 'replacement fact']); + }); + + test('fence claim matching an expired legacy row is inserted active — fence is canonical (#2646)', async () => { + // Deliberate semantics, pinned: legacy DB-only forgets are + // documented NOT to survive rebuild (forget.ts header — the + // explicit DB-only exception). When the fence still carries the + // same (claim, source), the reconcile inserts a fresh active + // fence-owned row; the expired legacy row survives alongside as + // the record of the earlier forget. Suppressing the insert would + // create silent fence↔DB divergence ("0 facts" while the fence + // says otherwise) — the exact failure mode the guard prevents. + // To durably forget, forget the fence-owned row (fence path). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, expired_at, source_markdown_slug) + VALUES ('default', 'people/alice', 'shared claim', 'fact', 'private', 'medium', + now(), 's', 1.0, now(), 'people/alice')`, + ); + + await putPage('people/alice', FACT_FENCE( + `| 1 | shared claim | fact | 1.0 | world | high | 2026-01-01 | | s | |`, + )); + + const r1 = await runExtractFacts(engine, { slugs: ['people/alice'] }); + const r2 = await runExtractFacts(engine, { slugs: ['people/alice'] }); + + expect(r1.factsInserted).toBe(1); + expect(r1.factsDeleted).toBe(0); + // Idempotent thereafter — the coexisting pair is stable state. + expect(r2.factsInserted).toBe(0); + expect(r2.factsDeleted).toBe(0); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = await (engine as any).db.query( + `SELECT fact, row_num, expired_at FROM facts + WHERE source_markdown_slug = 'people/alice' ORDER BY id`, + ); + expect(rows.rows).toHaveLength(2); + expect(rows.rows[0]).toMatchObject({ fact: 'shared claim', row_num: null }); + expect(rows.rows[0].expired_at).not.toBeNull(); // forget record preserved + expect(rows.rows[1]).toMatchObject({ fact: 'shared claim', row_num: 1 }); + expect(rows.rows[1].expired_at).toBeNull(); // fence-canonical active row + }); + + test('mixed active + expired legacy rows: guard counts only the active ones (#2646)', async () => { + // One active legacy row + one soft-expired legacy row. The guard + // must still trigger (an active row is pending backfill) but the + // pending count must exclude the expired row — so each forget_fact + // visibly drains the counter toward release. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `INSERT INTO facts (source_id, entity_slug, fact, kind, visibility, notability, + valid_from, source, confidence, expired_at) + VALUES + ('default', 'people/alice', 'active legacy claim', 'fact', 'private', 'medium', + now(), 'mcp:put_page', 1.0, NULL), + ('default', 'people/alice', 'expired legacy claim', 'fact', 'private', 'medium', + now(), 'mcp:put_page', 1.0, now())`, + ); + + await putPage('people/alice', FACT_FENCE( + `| 1 | new fact | fact | 1.0 | world | high | 2026-01-01 | | s | |`, + )); + + const r = await runExtractFacts(engine, { slugs: ['people/alice'] }); + + expect(r.guardTriggered).toBe(true); + expect(r.legacyRowsPending).toBe(1); + expect(r.factsInserted).toBe(0); + expect(r.factsDeleted).toBe(0); + }); + test('NULL entity_slug legacy rows do NOT trigger the guard (they are structurally unfenceable)', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any await (engine as any).db.query(