Citation verification: re-fetch, compare, badge the result
Gadoes closes the loop on citations: a user can now click "Verify & read" and the system re-fetches the source URL, checks whether the stored excerpt actually appears in the page, and updates a `verification_status` badge. Four commits, two new tables columns, one new service, one new route.
Schema (Task 1). Migration 006_citations_verification.sql adds verification_status TEXT NOT NULL DEFAULT 'pending' with a check constraint allowing only pending, verified, unverified, or unavailable. A separate savedCitationIds return value flows out of runLLMStream() so the route layer can back-link citations to their chat message after insert. italaw and ICSID citations are auto-set to verified on save because those sources are static scrapes - no live fetch needed.
Verifier service (Task 2). citationVerifier.ts is 46 lines. It fetches the URL with a 10-second AbortController timeout, strips HTML tags and collapses whitespace, then does a case-insensitive substring check for the excerpt. Returns verified, unverified, or unavailable. Empty excerpts go straight to unavailable without a fetch. The 101-line test covers the happy path, excerpt-not-found, HTTP error, network error, timeout, case-insensitivity, and empty excerpt.
Route (Task 3). POST /citations/:id/verify at routes/citations.ts (40 lines) fetches the citation by ID, returns 422 if there's no excerpt, calls CitationVerifier.verify(), updates verification_status, and returns the updated row. The 137-line test mocks both Supabase and CitationVerifier and covers 404, 422, and all three outcome states.
UI (Task 8). CitationCard.tsx grows a VerificationBadge component with four states: pending (spinner-like indicator), verified, unverified ("Excerpt not found"), unavailable. Clicking "Verify & read" calls the endpoint, updates local state, and hides the button entirely if the citation has no id.
The naming decision from Chunk 4 - keeping liveness_status separate from verification_status - pays off here. No schema collision.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?