diff --git a/src/core/cycle/extract-facts.ts b/src/core/cycle/extract-facts.ts index 94995539a..5c2d48343 100644 --- a/src/core/cycle/extract-facts.ts +++ b/src/core/cycle/extract-facts.ts @@ -17,11 +17,12 @@ * DB rows need cleanup (#1781 — the unconditional wipe-and-reinsert * made every cycle non-idempotent, re-appending duplicate rows). * - * After the phase, the DB index for every affected page matches the - * fence's canonical (claim, source) row set (modulo embeddings + - * runtime-derived fields). Pages with no fence wipe DB rows for that - * page coordinate only; legacy NULL-source_markdown_slug rows survive - * because deleteFactsForPage targets source_markdown_slug = slug only. + * After the phase, the DB index for every cleanly parsed affected page + * matches the fence's canonical (claim, source) row set (modulo embeddings + * + runtime-derived fields). Warning-bearing parses are non-authoritative + * and preserve that page's existing index. Pages with no fence wipe DB rows + * for that 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; #2646): the phase refuses to do * its destructive reconciliation pass when genuinely-backfillable legacy @@ -333,6 +334,11 @@ export async function runExtractFacts( result.warnings.push( ...parsed.warnings.map(w => `${slug}: ${w}`), ); + // The parser deliberately skips malformed rows and returns any rows it + // could still recover. That partial result is not authoritative: using + // it for reconciliation would interpret skipped rows as deletions. + // Preserve this page's existing index and continue with other pages. + continue; } if (parsed.facts.length > 0) result.pagesWithFacts += 1; diff --git a/test/extract-facts-phase.test.ts b/test/extract-facts-phase.test.ts index d45900ee1..2b53df545 100644 --- a/test/extract-facts-phase.test.ts +++ b/test/extract-facts-phase.test.ts @@ -231,6 +231,44 @@ describe('runExtractFacts — happy path', () => { expect(rows.rows[0].fact).toBe('A'); }); + test('malformed fence rows make the page non-authoritative and preserve its indexed facts', async () => { + await putPage('people/alice', FACT_FENCE( + `| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | | +| 2 | B | fact | 1.0 | world | medium | 2026-01-01 | | s | |`, + )); + await runExtractFacts(engine, { slugs: ['people/alice'] }); + + // A hand edit corrupts row 2. The parser can still recover row 1, but + // that partial result is not an authoritative replacement for the page. + await putPage('people/alice', FACT_FENCE( + `| 1 | A | fact | 1.0 | world | medium | 2026-01-01 | | s | | +| 2 | B | bogus | 1.0 | world | medium | 2026-01-01 | | s | |`, + )); + await putPage('people/bob', FACT_FENCE( + `| 1 | Clean | fact | 1.0 | world | medium | 2026-01-01 | | s | |`, + )); + + const r = await runExtractFacts(engine, { slugs: ['people/alice', 'people/bob'] }); + + expect(r.warnings.some(w => w.includes('FACTS_TABLE_MALFORMED'))).toBe(true); + expect(r.pagesScanned).toBe(2); + expect(r.factsInserted).toBe(1); + expect(r.factsDeleted).toBe(0); + + // 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 row_num`, + ); + expect(rows.rows.map((row: { fact: string }) => row.fact)).toEqual(['A', 'B']); + + // A warning is page-local: clean pages in the same cycle still reconcile. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cleanRows = await (engine as any).db.query( + `SELECT fact FROM facts WHERE source_markdown_slug = 'people/bob'`, + ); + expect(cleanRows.rows.map((row: { fact: string }) => row.fact)).toEqual(['Clean']); + }); + test('page with no facts fence → DB facts for that page wiped (empty fence reconciles to empty index)', async () => { await putPage('people/alice', FACT_FENCE( `| 1 | seeded | fact | 1.0 | world | medium | 2026-01-01 | | s | |`,