Skip to content

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 new citations); per-target discriminated-FK polymorphism; D1: the q_a_pair cited column ships DORMANT in v1; citing side via citing_entity_kind enum (form_response only 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.

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_citations with content_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_by ON DELETE SET NULL re-pointed to user_profiles by 20260503225703_…: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 on OLD/NEW.content_item_id, counts content_citations WHERE content_item_id = target, writes content_items.citation_count. ACL hardened by 20260502143049_ops43_revoke_anon_execute_public_functions.sql:151 (REVOKE EXECUTE … FROM PUBLIC, anon, authenticated). Bound by triggers trg_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 grep for the latest CREATE OR REPLACE per RPC):
    • get_aggregate_win_rate_stats() and get_content_win_rate(p_content_item_id uuid)20260606194939_id84_redefine_stale_bid_questions_project_id_rpcs.sql (ID-84.1). Both LANGUAGE plpgsql, SET search_path TO 'public','extensions', SECURITY INVOKER (proacl {postgres=X, authenticated=X, service_role=X} — anon/PUBLIC revoked), join content_citations cc → content_items → bid_responses br → bid_questions bq → workspaces w, read w.domain_metadata->>'outcome'.
    • hybrid_search(...) and search_for_bid_response(...)20260530121355_id197_hybrid_search_drift_fix.sql (ID-197). hybrid_search SECURITY INVOKER (:98); search_for_bid_response SECURITY INVOKER (“NEVER SECURITY DEFINER — S186 verifier L1”, :181). Both compute a win_stats CTE from content_citations cc JOIN bid_responses br … JOIN workspaces w keyed on cc.content_item_id + COUNT(DISTINCT cc.bid_response_id) (:107–116, :210–221), used as a win_boost multiplier on similarity.
  • 5 TS consumer sites (grep .from('content_citations') + the typed Insert), verified:
    • app/api/procurement/[id]/responses/draft-stream/route.ts:286–306sole writer: delete-by-bid_response_id then insert { bid_response_id, content_item_id, citation_type:'reference', created_by } per matched item.
    • lib/mcp/tools/procurement.ts:419–490 — the cite_content MCP tool. Typed Insert Database['public']['Tables']['content_citations']['Insert']; upsert onConflict:'content_item_id,bid_response_id'; .select('id, content_item_id, bid_response_id, citation_type'). inputSchema args content_item_id, bid_response_id, citation_type (:421–437).
    • app/api/items/[id]/effectiveness/route.ts:97–113 — reads citations with embedded bid_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 by auto_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 by q_a_pairs_history_trigger() as COALESCE(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.

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.

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 CASCADE

No 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 time
cited_q_a_pair_version integer NULL -- q_a_pair path (DORMANT, D1): q_a_pair_history.version

Per 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 unused

Plus 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 issued
GRANT 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 count
SELECT 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 ccFROM public.citations cc, with an added WHERE cc.cited_kind = 'content_item' (or AND cc.cited_kind = 'content_item' folded into the existing predicate).
  • cc.content_item_idcc.cited_content_item_id.
  • cc.bid_response_idcc.citing_form_response_id; JOIN bid_responses br ON br.id = cc.bid_response_idJOIN form_responses br ON br.id = cc.citing_form_response_id (the join chain br → bid_questions bq → workspaces w → domain_metadata->>'outcome' is otherwise unchanged; bid_questions/form_questions naming follows whatever {64.14} lands).

Per-RPC source of truth:

  • Inv-16 get_aggregate_win_rate_stats() — base body id84_…:205–293. citation_detail CTE re-pointed; COUNT(DISTINCT content_item_id)COUNT(DISTINCT cited_content_item_id); COUNT(DISTINCT workspace_id) unchanged. Caller app/api/analytics/win-rate/route.ts.
  • Inv-17 get_content_win_rate(p_content_item_id uuid) — base body id84_…:105–156. citation_outcomes CTE re-pointed; WHERE cc.content_item_id = p_content_item_idWHERE cc.cited_kind = 'content_item' AND cc.cited_content_item_id = p_content_item_id. Parameter + return shape unchanged. Caller app/api/items/[id]/effectiveness/route.ts.
  • Inv-18 hybrid_search(...) — base body id197_…:66–172. win_stats CTE re-pointed (GROUP BY cc.cited_content_item_id, COUNT(DISTINCT cc.citing_form_response_id)); the LEFT JOIN win_stats ws ON ws.content_item_id = ci.id and win_boost multiplier ordering contract unchanged.
  • Inv-19 search_for_bid_response(...) — base body id197_…:183–end. Same win_stats re-point. NEVER SECURITY DEFINER (S186 verifier L1 — preserve SECURITY INVOKER). Note the RPC name (and any rename to a form_* identifier) is owned by {64.14}, the citation-join body by T11; the re-point lands against whatever name exists post-{64.14}.
DROP TABLE public.content_citations; -- 0 rows; last in the migration, after all SQL consumers re-point

The 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.

  1. 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). The cited_version capture is a small added read (the matched-content query can SELECT the per-item current version alongside; R1). Inv-10: this default writer path continues to record citation_type = 'reference'.
  2. lib/mcp/tools/procurement.ts:419–490 (cite_content MCP tool, Inv-22) — typed Insert → Tables<'citations'>['Insert']; upsert onConflict:'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 name cite_content is UNCHANGED (preserves the mcp-fixture-sync.test.ts TOOL_COUNT / CANONICAL_TOOL_NAMES guard — see Testing). The inputSchema arg bid_response_id is the LLM-facing contract; recommend renaming the arg to form_response_id for naming consistency post-{64.14}, Liam call (it is an external-contract change — see Open questions). CitationResult / formatCitation / toStructuredContent shapes update to the new fields.
  3. app/api/items/[id]/effectiveness/route.ts:97–113 (Inv-20) — .from('citations'), .eq('cited_content_item_id', id) + cited_kind = 'content_item'; embed form_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.
  4. scripts/mcp-eval/fixtures.ts:478.from('citations').delete().eq('cited_content_item_id', id).
  5. scripts/mcp-eval/functional-correctness.ts:2276 — insert a test citations row 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 arm
ALTER 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 versioned

No 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).

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; with cited_q_a_pair_id set but cited_kind='content_item' → rejected; valid single-FK row → accepted. Delete the parent content_item → its citations CASCADE-deleted.
  • Inv-5/6 (citing CHECK + form_responses FK) — insert with citing_form_response_id NULL → 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 carry citation_type='reference'.
  • Inv-11/12/13 (version-on-cite) — writer test: cite a content_item, assert cited_version = current MAX(content_history.version); then edit the item (new history version) and re-read the original citation → cited_version unchanged. Assert population matches kind.
  • Inv-14 (citation_count trigger) — insert a content_item-cited row → content_items. citation_count increments; delete (and via CASCADE on item/form-response delete) → decrements; insert a q_a_pair-cited row (dormant path, manual) → count unchanged.
  • Inv-16/17/18/19 (RPC contracts)__tests__ + mcp-eval L4 (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]/effectiveness route test: returns the same “which form responses cited this item” list against new citations.
  • 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.ts MUST stay green — verify TOOL_COUNT / CANONICAL_TOOL_NAMES unchanged because cite_content is NOT renamed (only its body re-points). Re-run bun 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). The migration-revoke-guard.yml CI 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_citations over app/ lib/ scripts/ supabase/migrations/<new> returns no live reference; bun build
    • bun lint + bun run test clean (type regen first so Tables<'citations'> resolves).
  • Inv-15 (bl-74 gate) — no automated test (dormant path). Documented assertion: the q_a_pair cite path MUST NOT be exercised in production before bl-74 lands on q_a_pair_history ({64.8} G3 / ID-45 lane). T11 escalates if that ordering slips.
  • 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 against pre_squash + t6). The cite-time version is MAX(<history>.version). The DDL is unaffected (still an integer column); the writer reads the history MAX. Mitigation: the draft-stream cited_version capture 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_responses does not exist yet ({64.14} pending). The citing FK target is absent today; ID-58’s migration cannot apply until {64.14} lands the bid_responses → form_responses table 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} is done before dispatching the {58.5+} migration. Do NOT target bid_responses as 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_version read must not throw on items with no history (use COALESCE(MAX(version),0)). No sb() / 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 VALUE transaction constraint (future kinds only). Noted in the ADD-COLUMN recipe; not a v1 concern (both enum values are created fresh in one CREATE 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.yml will flag prod↔staging diff.
  • OQ-58-6 (MCP tool arg rename). The cite_content tool’s inputSchema arg is bid_response_id (LLM-facing). Re-point keeps the tool name (guard-safe) but the arg name is bid-coupled. Rename arg → form_response_id for post-{64.14} consistency (an external-contract change, may affect prompt/eval fixtures), or keep bid_response_id for contract stability? TECH recommends rename for consistency; Liam call.
  • OQ-58-7 (version-source phrasing). Confirm the R1 correction: store MAX(content_history.version) (no content_items.version column 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_indexmatchedContent[idx].id (fallback entry.source_id) → cited_content_item_id.
  • entry.cited_textcited_text; cited_location_kind = 'block'.
  • entry.start_block_indexcited_start; entry.end_block_indexcited_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.