fix(cycle): extract_facts guard counts only active legacy rows; reconcile preserves forget records (#2646) (#3474)

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>
This commit is contained in:
Masa
2026-07-28 11:44:17 -07:00
committed by GitHub
co-authored by Time Attakc
parent 2ac6959b46
commit a104f98dca
6 changed files with 345 additions and 20 deletions
+48 -14
View File
@@ -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;
+12 -1
View File
@@ -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 }>;
/**
+9 -3
View File
@@ -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 };
+9 -2
View File
@@ -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 };
}
@@ -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<void> {
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);
});
+180
View File
@@ -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(