mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
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:
@@ -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
@@ -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 }>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user