ID-58 {58.3} TECH — Citations polymorphic cite-target + version-on-cite (T11)
ID-58 {58.3} TECH — Citations polymorphic cite-target + version-on-cite
Section titled “ID-58 {58.3} TECH — Citations polymorphic cite-target + version-on-cite”Status: TECH draft — PENDING Liam ratification. Authored 08/06/2026 by a FRESH planner instance (Q-PLANNER-2: NOT the {58.1}/{58.2} author). Reads the ratified {58.2} PRODUCT (25 invariants) and {58.1} RESEARCH in full. Implements the ratified dispositions (Liam, 08/06/2026):
content_citations= REPLACE (DROP the 0-row table; CREATE newcitations); per-target discriminated-FK polymorphism; D1: theq_a_paircited column ships DORMANT in v1; citing side viaciting_entity_kindenum (form_responseonly valid in v1); inline integer version-on-cite per kind; TS consumer re-point SAME-PR as the migration; ID-58 sequences AFTER ID-64 {64.14}.Inputs read in full:
{58.2}PRODUCT,{58.1}RESEARCH (same spec dir). No DDL applied here — spec only. DDL via Supabase CLI, staging-first; prod push is a Liam gate.
Context
Section titled “Context”This Task replaces the fixed content_items × bid_responses junction content_citations
with a polymorphic citations table. Behaviour is owned by {58.2} PRODUCT (Inv-1..25);
this TECH owns the migration-ready DDL slice + the consumer re-point. The “user” is the
code that reads/writes citations: the bid-draft writer, the cite_content MCP tool, the
effectiveness reader, the four win-rate RPCs, and the citation_count trigger.
Code-intelligence orientation (recorded, per the planner code-intel block). gitnexus
was UNAVAILABLE at authoring time (consistent with {58.1} §1.1: LadybugDB … version 41 vs build 40, index mid-rebuild). This is in any case the authoritative path for this Task:
ast-dataflow/gitnexus do not index SQL migrations or Python (per .ast-dataflow/CLAUDE.md),
and the entire citations surface is SQL (1 table + 1 trigger fn + 4 RPCs) plus a handful of
TS .from('content_citations') sites. The consumer surface was therefore re-mapped by
direct grep + migration read (the authoritative path for this SQL-only surface).
gitnexus orientation: no live symbol index available — SQL/Python surface mapped via grep + migration read.
Current state — verified against the migration corpus (08/06/2026):
- Table (
20260416102457_pre_squash_reconciliation.sql:3471–3479):content_citationswithcontent_item_id uuid NOT NULL(cited FK →content_items),bid_response_id uuid NOT NULL(citing FK →bid_responses),citation_type text DEFAULT 'reference' CHECK ∈ {reference,copied,adapted,inspired}(:3478),created_at,created_by. UNIQUE(content_item_id, bid_response_id)(:4298); indexes_item/_response/_created_by(:4635–4643); FKs ON DELETE CASCADE (:5335,:5340),created_byON DELETE SET NULL re-pointed touser_profilesby20260503225703_…:224.GRANT ALL … TO anon(:8289) — the REVOKE-from-anon hygiene item (Inv-24). Row count 0 on staging + prod. - Trigger fn
update_citation_count()— authoritative body still at pre_squash:3254(SECURITY DEFINER,SET search_path TO 'public','extensions'); keys onOLD/NEW.content_item_id, countscontent_citations WHERE content_item_id = target, writescontent_items.citation_count. ACL hardened by20260502143049_ops43_revoke_anon_execute_public_functions.sql:151(REVOKE EXECUTE … FROM PUBLIC, anon, authenticated). Bound by triggerstrg_citation_count_insert/delete(pre_squash:5243,:5247). - 4 RPCs — authoritative bodies are NOT pre_squash (the {58.1} line refs are the
original defs; the bodies have been redefined since — verified by
grepfor the latest CREATE OR REPLACE per RPC):get_aggregate_win_rate_stats()andget_content_win_rate(p_content_item_id uuid)→20260606194939_id84_redefine_stale_bid_questions_project_id_rpcs.sql(ID-84.1). BothLANGUAGE plpgsql,SET search_path TO 'public','extensions', SECURITY INVOKER (proacl{postgres=X, authenticated=X, service_role=X}— anon/PUBLIC revoked), joincontent_citations cc → content_items → bid_responses br → bid_questions bq → workspaces w, readw.domain_metadata->>'outcome'.hybrid_search(...)andsearch_for_bid_response(...)→20260530121355_id197_hybrid_search_drift_fix.sql(ID-197).hybrid_searchSECURITY INVOKER (:98);search_for_bid_responseSECURITY INVOKER (“NEVER SECURITY DEFINER — S186 verifier L1”,:181). Both compute awin_statsCTE fromcontent_citations cc JOIN bid_responses br … JOIN workspaces wkeyed oncc.content_item_id+COUNT(DISTINCT cc.bid_response_id)(:107–116,:210–221), used as awin_boostmultiplier onsimilarity.
- 5 TS consumer sites (grep
.from('content_citations')+ the typed Insert), verified:app/api/procurement/[id]/responses/draft-stream/route.ts:286–306— sole writer: delete-by-bid_response_idthen insert{ bid_response_id, content_item_id, citation_type:'reference', created_by }per matched item.lib/mcp/tools/procurement.ts:419–490— thecite_contentMCP tool. Typed InsertDatabase['public']['Tables']['content_citations']['Insert']; upsertonConflict:'content_item_id,bid_response_id';.select('id, content_item_id, bid_response_id, citation_type'). inputSchema argscontent_item_id,bid_response_id,citation_type(:421–437).app/api/items/[id]/effectiveness/route.ts:97–113— reads citations with embeddedbid_responses!inner → bid_questions!inner → workspaces!inner, filtered.eq('content_item_id', id).scripts/mcp-eval/fixtures.ts:478—.delete().eq('content_item_id', id).scripts/mcp-eval/functional-correctness.ts:2276— inserts a test citation row.
Version substrate — schema-reality correction (load-bearing; see Risks R1). Verified
against the migration corpus: neither content_items nor q_a_pairs carries a version
column. The current revision lives only in the history tables:
content_history.version integer NOT NULL, UNIQUE(content_item_id, version)(pre_squash:3488,:4308), written byauto_version_content_history()(BEFORE INSERT,:5195).q_a_pair_history.version integer NOT NULL, UNIQUE(q_a_pair_id, version)(20260520225456_t6_q_a_pairs_full_schema.sql:157,:173), written byq_a_pairs_history_trigger()asCOALESCE(MAX(version),0)+1(AFTER UPDATE,:201).
So the cite-time “current version” of a target is COALESCE((SELECT MAX(version) FROM <history> WHERE <fk> = X), 0) — not a content_items.version / q_a_pairs.version
read. {58.1} §6.1 and {58.2} Inv-12 phrase it as content_items / q_a_pairs.version; that
column does not exist. The DDL columns are unaffected (the citation still stores an integer);
only the write-site read changes. The content_item writer path is in v1 scope; the
q_a_pair path ships DORMANT (D1), so its version read is deferred with the rest of that path.
Sequencing-reality correction (load-bearing; see Risks R2). {58.2} Inv-6 and the ratified
ordering assume form_responses exists. It does not yet: there is no form_responses
table in the migration corpus, and ledger {64.14} (the bid_responses → form_responses
table + search_for_bid_response RPC rename) is pending. {64.14} so far has landed only
the origin_kind value rename (20260601180058_…), which explicitly defers the table rename
(OQ-64-3, comment :13). Therefore the citing FK target (form_responses) and the
form-response naming in the RPC joins do not exist to reference today. ID-58 is correctly
gated AFTER {64.14}; this TECH records the gate as a hard pre-req and writes the DDL against
the post-{64.14} form_responses name.
Proposed changes
Section titled “Proposed changes”One migration (one PR), CLI-authored (supabase migration new id58_citations_polymorphic_replace),
staging-first. The migration is ordered: (1) enums, (2) citations table + constraints +
indexes + RLS + grants, (3) re-point trigger fn, (4) re-point 4 RPCs, (5) DROP
content_citations LAST (after all SQL consumers re-point); the 5 TS sites re-point in the
SAME PR (Inv-25, OQ-58-4). Maps one proposed change per PRODUCT invariant.
Migration — DDL slice
Section titled “Migration — DDL slice”Inv-1 (cited_kind enum + NOT NULL). Create cited_target_kind enum and a NOT-NULL
cited_kind column:
CREATE TYPE public.cited_target_kind AS ENUM ('content_item', 'q_a_pair');CREATE TYPE public.citing_entity_kind AS ENUM ('form_response');cited_kind cited_target_kind NOT NULL on the table — every row declares exactly one kind.
Inv-2 (one cited FK per kind + one-of CHECK). Two nullable discriminated FK columns + the load-bearing one-and-only-one CHECK:
cited_content_item_id uuid NULL,cited_q_a_pair_id uuid NULL, -- DORMANT in v1 (D1) — present, unused until bl-74...CONSTRAINT citations_cited_one_of_chk CHECK ( (cited_kind = 'content_item' AND cited_content_item_id IS NOT NULL AND cited_q_a_pair_id IS NULL) OR (cited_kind = 'q_a_pair' AND cited_q_a_pair_id IS NOT NULL AND cited_content_item_id IS NULL))Both-null, both-set, and kind/column mismatch are all rejected.
Inv-3 (per-kind FK integrity + CASCADE). FK constraints, each ON DELETE CASCADE:
CONSTRAINT citations_cited_content_item_id_fkey FOREIGN KEY (cited_content_item_id) REFERENCES public.content_items(id) ON DELETE CASCADE,CONSTRAINT citations_cited_q_a_pair_id_fkey FOREIGN KEY (cited_q_a_pair_id) REFERENCES public.q_a_pairs(id) ON DELETE CASCADENo silent orphans of the matched_content_ids uuid[] class (ID-93 §4). Deleting a target
removes its citations, keeping citation_count honest (Inv-14).
Inv-4 (cite-target extension by ADD COLUMN). No v1 DDL beyond the two kinds; documented as the forward-only recipe below (“Forward-only ADD COLUMN recipe”).
Inv-5 (citing_kind enum + NOT NULL DEFAULT). citing_kind citing_entity_kind NOT NULL DEFAULT 'form_response'. In v1 the only valid value is form_response.
Inv-6 (citing FK → form_responses, CASCADE, citing CHECK).
citing_form_response_id uuid NULL,...CONSTRAINT citations_citing_form_response_id_fkey FOREIGN KEY (citing_form_response_id) REFERENCES public.form_responses(id) ON DELETE CASCADE,CONSTRAINT citations_citing_one_of_chk CHECK ( citing_kind = 'form_response' AND citing_form_response_id IS NOT NULL)Hard pre-req (R2): public.form_responses must exist — i.e. {64.14} must have landed —
before this migration applies. If {64.14} has not landed at apply time, the FK target is
missing and the migration MUST NOT be force-pointed at bid_responses (that would re-introduce
the bid-coupled name {64.14} is removing). T11 escalates rather than re-targeting.
Inv-7 (citing-side generalisable). No v1 DDL beyond form_response; same ADD-COLUMN
recipe as the cited side.
Inv-8 + Inv-9 (citation_type + value CHECK + default). Preserved verbatim from the current table:
citation_type text NOT NULL DEFAULT 'reference' CONSTRAINT citations_citation_type_chk CHECK (citation_type IN ('reference', 'copied', 'adapted', 'inspired'))reference = plain citation; copied/adapted/inspired = copy-events (the bl-136
data-capture half).
Inv-11 + Inv-12 + Inv-13 (version-on-cite columns). Two nullable integer columns, denormalised revision pointers (NOT FKs to a history-row id):
cited_version integer NULL, -- content_item path: content_history.version at cite timecited_q_a_pair_version integer NULL -- q_a_pair path (DORMANT, D1): q_a_pair_history.versionPer Inv-12 the populated version column matches the populated cited kind. Write-site source
(R1 correction): the current version is COALESCE((SELECT MAX(version) FROM content_history WHERE content_item_id = X), 0) for the content_item path, and the equivalent over
q_a_pair_history for the (dormant) q_a_pair path — there is no content_items.version /
q_a_pairs.version column to read. The stored integer is immutable under later target edits
(Inv-13). No CHECK couples the version column to the kind in v1 (the value is denormalised and
may legitimately be 0/NULL for an unversioned item); the population rule lives at the write
site, asserted by tests.
Inv-21 (per-kind partial-unique dedup indexes). Replace today’s UNIQUE
(content_item_id, bid_response_id) with per-kind partial-unique indexes:
CREATE UNIQUE INDEX citations_uniq_form_response_content_item ON public.citations (citing_form_response_id, cited_content_item_id) WHERE cited_kind = 'content_item';CREATE UNIQUE INDEX citations_uniq_form_response_q_a_pair ON public.citations (citing_form_response_id, cited_q_a_pair_id) WHERE cited_kind = 'q_a_pair'; -- DORMANT-path index; harmless while unusedPlus per-kind read indexes:
CREATE INDEX idx_citations_cited_content_item ON public.citations (cited_content_item_id) WHERE cited_kind = 'content_item';CREATE INDEX idx_citations_cited_q_a_pair ON public.citations (cited_q_a_pair_id) WHERE cited_kind = 'q_a_pair';CREATE INDEX idx_citations_citing_form_response ON public.citations (citing_form_response_id);CREATE INDEX idx_citations_created_by ON public.citations (created_by);The content_item partial-unique index is the onConflict target the re-pointed
cite_content upsert uses (Inv-22).
Inv-23 (RLS role matrix). Same matrix as content_citations (pre_squash:5757–5871):
ALTER TABLE public.citations ENABLE ROW LEVEL SECURITY;CREATE POLICY "Authenticated users can view citations" ON public.citations FOR SELECT TO authenticated USING (true);CREATE POLICY "Editors and admins can manage citations" ON public.citations FOR INSERT TO authenticated WITH CHECK (public.get_user_role() = ANY (ARRAY['admin','editor']));CREATE POLICY "Editors and admins can update citations" ON public.citations FOR UPDATE TO authenticated USING (public.get_user_role() = ANY (ARRAY['admin','editor']));CREATE POLICY "Admins can delete citations" ON public.citations FOR DELETE TO authenticated USING (public.get_user_role() = 'admin');SELECT = any authenticated; INSERT/UPDATE = editor+admin; DELETE = admin.
Inv-24 (REVOKE-from-anon hygiene). The current table carries GRANT ALL … TO anon
(pre_squash:8289); the new table MUST NOT. Explicit grants:
REVOKE ALL ON TABLE public.citations FROM anon; -- belt-and-braces; no GRANT … TO anon issuedGRANT ALL ON TABLE public.citations TO authenticated, service_role;Post-migration assertion: anon holds no privilege on public.citations
(pg_class.relacl / has_table_privilege('anon','public.citations','SELECT') → false).
Full column list for the table:
CREATE TABLE public.citations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), citing_kind public.citing_entity_kind NOT NULL DEFAULT 'form_response', citing_form_response_id uuid NULL, cited_kind public.cited_target_kind NOT NULL, cited_content_item_id uuid NULL, cited_q_a_pair_id uuid NULL, -- DORMANT v1 (D1) cited_version integer NULL, cited_q_a_pair_version integer NULL, -- DORMANT v1 (D1) citation_type text NOT NULL DEFAULT 'reference', created_at timestamptz NOT NULL DEFAULT now(), created_by uuid NULL, -- + the FK constraints (Inv-3, Inv-6), one-of CHECKs (Inv-2, Inv-6), -- citation_type CHECK (Inv-8), and created_by FK → user_profiles ON DELETE SET NULL -- (mirrors content_citations post-20260503225703));Migration — re-point trigger fn (Inv-14)
Section titled “Migration — re-point trigger fn (Inv-14)”CREATE OR REPLACE FUNCTION public.update_citation_count() re-pointed to the new columns,
preserving SECURITY DEFINER + SET search_path = public, extensions + the existing ACL
(REVOKE EXECUTE … FROM PUBLIC, anon, authenticated per ops43:151). Body change: key on
cited_content_item_id and count only cited_kind = 'content_item' rows:
target_id := COALESCE(NEW.cited_content_item_id, OLD.cited_content_item_id);IF target_id IS NULL THEN RETURN COALESCE(NEW, OLD); END IF; -- q_a_pair-cited: no content countSELECT count(*)::int INTO new_count FROM public.citations WHERE cited_kind = 'content_item' AND cited_content_item_id = target_id;UPDATE public.content_items SET citation_count = new_count WHERE id = target_id;Re-bind triggers to the new table:
CREATE TRIGGER trg_citation_count_insert AFTER INSERT ON public.citations FOR EACH ROW EXECUTE FUNCTION public.update_citation_count();CREATE TRIGGER trg_citation_count_delete AFTER DELETE ON public.citations FOR EACH ROW EXECUTE FUNCTION public.update_citation_count();(The old triggers vanish with DROP TABLE content_citations.) q_a_pair-cited rows do not
touch content_items.citation_count.
Migration — re-point the 4 RPCs (Inv-16..19)
Section titled “Migration — re-point the 4 RPCs (Inv-16..19)”Each RPC is re-pointed against the authoritative current body (NOT the pre_squash body),
preserving its RETURNS clause byte-for-byte, LANGUAGE, SECURITY INVOKER clause,
SET search_path TO 'public','extensions', and ACL (REVOKE EXECUTE … FROM PUBLIC, anon; GRANT EXECUTE … TO authenticated, service_role). The body change is mechanical:
FROM content_citations cc→FROM public.citations cc, with an addedWHERE cc.cited_kind = 'content_item'(orAND cc.cited_kind = 'content_item'folded into the existing predicate).cc.content_item_id→cc.cited_content_item_id.cc.bid_response_id→cc.citing_form_response_id;JOIN bid_responses br ON br.id = cc.bid_response_id→JOIN form_responses br ON br.id = cc.citing_form_response_id(the join chainbr → bid_questions bq → workspaces w → domain_metadata->>'outcome'is otherwise unchanged;bid_questions/form_questionsnaming follows whatever {64.14} lands).
Per-RPC source of truth:
- Inv-16
get_aggregate_win_rate_stats()— base bodyid84_…:205–293.citation_detailCTE re-pointed;COUNT(DISTINCT content_item_id)→COUNT(DISTINCT cited_content_item_id);COUNT(DISTINCT workspace_id)unchanged. Callerapp/api/analytics/win-rate/route.ts. - Inv-17
get_content_win_rate(p_content_item_id uuid)— base bodyid84_…:105–156.citation_outcomesCTE re-pointed;WHERE cc.content_item_id = p_content_item_id→WHERE cc.cited_kind = 'content_item' AND cc.cited_content_item_id = p_content_item_id. Parameter + return shape unchanged. Callerapp/api/items/[id]/effectiveness/route.ts. - Inv-18
hybrid_search(...)— base bodyid197_…:66–172.win_statsCTE re-pointed (GROUP BY cc.cited_content_item_id,COUNT(DISTINCT cc.citing_form_response_id)); theLEFT JOIN win_stats ws ON ws.content_item_id = ci.idandwin_boostmultiplier ordering contract unchanged. - Inv-19
search_for_bid_response(...)— base bodyid197_…:183–end. Samewin_statsre-point. NEVER SECURITY DEFINER (S186 verifier L1 — preserve SECURITY INVOKER). Note the RPC name (and any rename to aform_*identifier) is owned by {64.14}, the citation-join body by T11; the re-point lands against whatever name exists post-{64.14}.
Migration — DROP last (Inv-25)
Section titled “Migration — DROP last (Inv-25)”DROP TABLE public.content_citations; -- 0 rows; last in the migration, after all SQL consumers re-pointThe two old trg_citation_count_* triggers and the old table FKs/indexes/policies drop with it.
TS consumer re-point (SAME-PR, Inv-25 / OQ-58-4)
Section titled “TS consumer re-point (SAME-PR, Inv-25 / OQ-58-4)”Five sites, same PR as the migration (the old table is dropped, so a fast-follow would leave a
broken-build window). The Insert/Select shapes change to the new columns. After the migration
applies on staging, regenerate types (supabase gen types … > supabase/types/database.types.ts)
so Tables<'citations'> resolves before editing TS.
app/api/procurement/[id]/responses/draft-stream/route.ts:286–306(writer) — insert shape{ citing_kind:'form_response', citing_form_response_id: response.id, cited_kind:'content_item', cited_content_item_id: c.id, cited_version: <MAX(content_history.version) for c.id>, citation_type:'reference', created_by: user.id }; delete-by becomes.eq('citing_form_response_id', response.id). Thecited_versioncapture is a small added read (the matched-content query canSELECTthe per-item current version alongside; R1). Inv-10: this default writer path continues to recordcitation_type = 'reference'.lib/mcp/tools/procurement.ts:419–490(cite_contentMCP tool, Inv-22) — typed Insert →Tables<'citations'>['Insert']; upsertonConflict:'citing_form_response_id,cited_content_item_id'(matches the partial-unique index);.select('id, cited_kind, cited_content_item_id, citing_kind, citing_form_response_id, citation_type, cited_version'). Tool namecite_contentis UNCHANGED (preserves themcp-fixture-sync.test.tsTOOL_COUNT / CANONICAL_TOOL_NAMES guard — see Testing). The inputSchema argbid_response_idis the LLM-facing contract; recommend renaming the arg toform_response_idfor naming consistency post-{64.14}, Liam call (it is an external-contract change — see Open questions).CitationResult/formatCitation/toStructuredContentshapes update to the new fields.app/api/items/[id]/effectiveness/route.ts:97–113(Inv-20) —.from('citations'),.eq('cited_content_item_id', id)+cited_kind = 'content_item'; embedform_responses!inner → form_questions/bid_questions!inner → workspaces!inner(the embed alias follows the {64.14} table name). Returns the same logical “which form responses cited this item” list.scripts/mcp-eval/fixtures.ts:478—.from('citations').delete().eq('cited_content_item_id', id).scripts/mcp-eval/functional-correctness.ts:2276— insert a testcitationsrow with the new polymorphic + version fields (cited_kind:'content_item',citing_kind:'form_response').
Forward-only ADD COLUMN recipe (Inv-4, Inv-7 — documented, no v1 DDL)
Section titled “Forward-only ADD COLUMN recipe (Inv-4, Inv-7 — documented, no v1 DDL)”To add a future cited kind X (e.g. a KG node / reference_item) OR a future citing kind:
ALTER TYPE public.cited_target_kind ADD VALUE 'X'; -- (or citing_entity_kind)ALTER TABLE public.citations ADD COLUMN cited_x_id uuid NULL CONSTRAINT citations_cited_x_id_fkey REFERENCES public.x_table(id) ON DELETE CASCADE;-- extend the one-of CHECK: drop + recreate with the new armALTER TABLE public.citations DROP CONSTRAINT citations_cited_one_of_chk;ALTER TABLE public.citations ADD CONSTRAINT citations_cited_one_of_chk CHECK ( … OR (cited_kind='X' AND cited_x_id IS NOT NULL AND <others> IS NULL) );-- add the partial-unique + read indexes WHERE cited_kind='X'; add a cited_x_version column if versionedNo existing row changes. ALTER TYPE … ADD VALUE cannot run inside a transaction block with
subsequent use of the new value in the same migration — split the enum-extension and the
column/CHECK work across two migrations if the new value is referenced immediately (a Postgres
constraint worth noting for the future implementer).
Testing and validation
Section titled “Testing and validation”bun run test (not bun test). Behaviour-change tests verify real behaviour per
${KH_PRIVATE_DOCS_DIR}/src/content/docs/reference/test-philosophy.md — no mock-shaped assertions. The table is at 0 rows,
so all validation is fresh-insert, no backfill.
- Inv-1/2/3 (one-of CHECK + FK integrity) — DB-level test (or staging psql): insert
cited_kind='content_item'with both FKs set → rejected; with neither set → rejected; withcited_q_a_pair_idset butcited_kind='content_item'→ rejected; valid single-FK row → accepted. Delete the parentcontent_item→ its citations CASCADE-deleted. - Inv-5/6 (citing CHECK + form_responses FK) — insert with
citing_form_response_idNULL → rejected; FK to a non-existent form response → rejected. Gated on {64.14} (R2). - Inv-8/9/10 (citation_type) — invalid
citation_type→ rejected; default →'reference'; writer path (draft-stream) integration test asserts inserted rows carrycitation_type='reference'. - Inv-11/12/13 (version-on-cite) — writer test: cite a content_item, assert
cited_version= currentMAX(content_history.version); then edit the item (new history version) and re-read the original citation →cited_versionunchanged. Assert population matches kind. - Inv-14 (citation_count trigger) — insert a
content_item-cited row →content_items. citation_countincrements; delete (and via CASCADE on item/form-response delete) → decrements; insert aq_a_pair-cited row (dormant path, manual) → count unchanged. - Inv-16/17/18/19 (RPC contracts) —
__tests__+mcp-evalL4 (live DB): each RPC returns the same shape as today for equivalent data. Regression anchors: the existing win-rate / effectiveness / search tests (no-bid-regression-guard.test.ts,pipeline-parity.test.ts, mcp-eval FC-31/FC-51). Confirm RETURNS clauses byte-identical (CREATE OR REPLACE legality). - Inv-20 (effectiveness reader) —
app/api/items/[id]/effectivenessroute test: returns the same “which form responses cited this item” list against newcitations. - Inv-21/22 (dedup + MCP tool) — re-cite the same (form_response, content_item) pair via
cite_content→ idempotent upsert (one row, type updated, not a second row).mcp-fixture-sync.test.tsMUST stay green — verifyTOOL_COUNT/CANONICAL_TOOL_NAMESunchanged becausecite_contentis NOT renamed (only its body re-points). Re-runbun run test:mcp-eval(L1 protocol) +:fc(L4 functional) after the re-point. - Inv-23 (RLS) — viewer can SELECT; non-editor INSERT rejected; non-admin DELETE rejected.
- Inv-24 (anon REVOKE) — assert
has_table_privilege('anon','public.citations','SELECT')= false (and INSERT/UPDATE/DELETE). Themigration-revoke-guard.ymlCI lint covers the anon-EXECUTE class; the table-grant assertion is an explicit migration post-check. - Inv-25 (DROP, no dangling consumer) — after the migration,
grep -r content_citationsoverapp/ lib/ scripts/ supabase/migrations/<new>returns no live reference;bun buildbun lint+bun run testclean (type regen first soTables<'citations'>resolves).
- Inv-15 (bl-74 gate) — no automated test (dormant path). Documented assertion: the
q_a_paircite path MUST NOT be exercised in production before bl-74 lands onq_a_pair_history({64.8} G3 / ID-45 lane). T11 escalates if that ordering slips.
Risks and mitigations
Section titled “Risks and mitigations”- R1 — version source drift (resolved here). {58.1}/{58.2} say read
content_items.version/q_a_pairs.version; those columns do not exist (verified againstpre_squash+ t6). The cite-time version isMAX(<history>.version). The DDL is unaffected (still an integer column); the writer reads the history MAX. Mitigation: the draft-streamcited_versioncapture is folded into the matched-content query, asserted by the Inv-11 test. The dormant q_a_pair version read is deferred with D1. - R2 —
form_responsesdoes not exist yet ({64.14} pending). The citing FK target is absent today; ID-58’s migration cannot apply until {64.14} lands thebid_responses → form_responsestable rename. Mitigation: ID-58 is correctly Task-level gated AFTER ID-64 {64.14} (ledger dep ID-58 → ID-64); the {58.4} PLAN / Orchestrator MUST confirm {64.14} isdonebefore dispatching the {58.5+} migration. Do NOT targetbid_responsesas a stop-gap — that re-introduces the bid-coupled name. Escalate if the ordering slips. - R3 — RPC body drift (the {58.1} line refs are stale). The pre_squash RPC bodies have been superseded (id84 + id197). Mitigation: re-point against the authoritative latest body per RPC (cited above), not pre_squash; preserve RETURNS byte-for-byte so CREATE OR REPLACE is legal and ACLs survive. Re-run mcp-eval L4 (FC-31/FC-51) post-re-point.
- R4 — silent Supabase failure at the writer. The draft-stream writer wraps the citation
insert in try/catch (non-fatal). The re-point keeps that posture; the added
cited_versionread must not throw on items with no history (useCOALESCE(MAX(version),0)). Nosb()/tryQuery()retrofit is in scope (the writer is pre-existing); flag as a follow-up if a composite-warnings envelope is wanted. - R5 —
ALTER TYPE … ADD VALUEtransaction constraint (future kinds only). Noted in the ADD-COLUMN recipe; not a v1 concern (both enum values are created fresh in oneCREATE TYPE). - R6 — staging/prod parity. Migration is staging-first; prod push is a Liam gate. Verify
cat supabase/.temp/project-ref+ relink to staging (turayklvaunphgbgscat) before any push (CLAUDE.md project-ref drift gotcha).schema-parity.ymlwill flag prod↔staging diff.
Open questions for Liam
Section titled “Open questions for Liam”- OQ-58-6 (MCP tool arg rename). The
cite_contenttool’s inputSchema arg isbid_response_id(LLM-facing). Re-point keeps the tool name (guard-safe) but the arg name is bid-coupled. Rename arg →form_response_idfor post-{64.14} consistency (an external-contract change, may affect prompt/eval fixtures), or keepbid_response_idfor contract stability? TECH recommends rename for consistency; Liam call. - OQ-58-7 (version-source phrasing). Confirm the R1 correction: store
MAX(content_history.version)(nocontent_items.versioncolumn exists). This is a schema-reality fix to {58.1} §6.1 / {58.2} Inv-12 phrasing, not a behaviour change — the invariant (stamp the cited revision) holds. - Inv-15 / R2 ordering confirmation. Confirm {64.14} (form_responses rename) and {64.8} G3 (bl-74) still own their DDL and land before ID-58’s migration + before the q_a_pair path is exercised. If either has moved, T11 needs re-briefing.
PC-26 / R3 — span-capture columns + per-citation writer cardinality
Section titled “PC-26 / R3 — span-capture columns + per-citation writer cardinality”Addendum authored S331 (per {58.6}, D-S330-1) — keeps the ratified invariant count stable.
Writer: app/api/procurement/[id]/responses/draft-stream/route.ts (sole citation writer; re-pointed from content_citations to public.citations in {58.6}).
Cardinality contract (load-bearing — keeps Inv-14 + the win-rate RPC’s COUNT(DISTINCT cited_content_item_id) regression-free): the writer emits one citations row per DISTINCT matched content item, never one row per Anthropic CitationEntry. The per-kind partial-unique index citations_uniq_form_response_content_item (citing_form_response_id, cited_content_item_id) WHERE cited_kind = 'content_item' is the cardinality guarantee at the DB level.
Per-CitationEntry → row mapping (D-S330-1):
entry.source_index→matchedContent[idx].id(fallbackentry.source_id) →cited_content_item_id.entry.cited_text→cited_text;cited_location_kind = 'block'.entry.start_block_index→cited_start;entry.end_block_index→cited_end.cited_kind = 'content_item',citing_kind = 'form_response',citing_form_response_id = <response id>.
Span collision resolution: when multiple CitationEntry resolve to the same content item, the FIRST/primary span is written to the single row; full multi-span fidelity is retained in form_responses.metadata.citations_data (JSONB). This keeps the relational row count at exactly one per cited item.
Coverage preservation (Inv-10): matched-content items that yielded no Anthropic citation are still written as citation_type = 'reference' rows with span columns left NULL. Win-rate coverage is therefore identical to the legacy content_citations behaviour.
Version-on-cite (R1): content_items carries no version column. The cited version is read as cited_version = COALESCE(MAX(content_history.version) WHERE content_item_id = X, 0), folded into the matched-content fetch as a single content_history .in(content_item_id, matchedIds) read reduced to a MAX map. The read is non-fatal (R4): on error it logs a warning and cited_version degrades to 0 rather than blocking the draft.
Idempotency: re-draft clears the response’s citations (delete().eq('citing_form_response_id', responseId)) then inserts the freshly resolved one-row-per-item set — equivalent to an upsert on (citing_form_response_id, cited_content_item_id) for the form_response citing kind.
Failure posture: the writer’s try/catch stays non-fatal (the response is already persisted), but the silent swallow is removed: failures now logger.error and emit a new SSE citation_warning event so a citation-write regression is observable client-side.