Skip to content

ID-58 {58.2} PRODUCT — Citations polymorphic cite-target + version-on-cite (T11)

ID-58 {58.2} PRODUCT — Citations polymorphic cite-target + version-on-cite

Section titled “ID-58 {58.2} PRODUCT — Citations polymorphic cite-target + version-on-cite”

Status: PRODUCT draft — PENDING Liam ratification. Authored 08/06/2026 by a FRESH planner instance (Q-PLANNER-2: NOT the {58.1} RESEARCH author). Built on the ratified {58.1} dispositions (Liam, 08/06/2026): REPLACE content_citations with a new citations table; per-target nullable discriminated-FK polymorphism; inline integer version-on-cite; form_response citing side via a citing_entity_kind enum; ID-58 sequences AFTER ID-64 {64.14}. This PRODUCT does NOT re-litigate those — it specifies the observable behaviour the {58.3} TECH will implement directly.

Inputs: {58.1} RESEARCH (ratified, same spec dir); specs/ID-93-pre-reingest-intent-gap/RESEARCH.md (coupling register).

The citations surface records, for each citing artefact, which knowledge-base target it cited, what kind of citation it was (reference vs copied/adapted/inspired), and which revision of that target was cited at the moment of citing. It replaces the current content_citations junction — a fixed content_items × bid_responses 2-FK table — with a polymorphic table whose cited side can address a content_item OR a q_a_pair (and, by extension, future kinds), whose citing side is modelled as a generalisable form_response, and which stamps the cited target’s version at cite time so “this bid cited v3 of item X” survives X being edited to v4. The win-rate analytics that today read content_citations are preserved verbatim against the new shape.

Three structural defects in the current content_citations table block the v1 knowledge surface:

  1. Fixed cited side. A citation can only ever point at a content_item. The bid-draft and copy-tracking surfaces increasingly need to cite a q_a_pair (a promoted Q&A pair) as well — there is no column, enum, or discriminator for it today.
  2. No version pointer. A citation records that item X was cited, never which revision. When X is later edited, every historic citation silently re-points to the new body — losing the audit answer “what content did this winning bid actually quote”.
  3. bid_response-coupled naming. The citing FK targets bid_responses, which ID-64 {64.14} renames to form_responses. The forward-referenced citing_entity enum value that would generalise the citing side does not exist yet (only a forward-comment in 20260601180058_id64_origin_kind_rename_form_response.sql:13).

Code-intelligence orientation (recorded, per {58.1} §1.1): gitnexus_query / gitnexus_impact were UNAVAILABLE at spec-authoring time (LadybugDB unavailable for knowledge-hub … Database file version: 41, Current build storage version: 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') call sites. The consumer surface was therefore mapped by direct grep + live-schema read + RPC inspection ({58.1} §3.3, verified 08/06/2026): 5 live TS call sites + 1 trigger fn + 4 RPCs, against a table at 0 rows on staging and prod. gitnexus orientation: no live symbol index available — SQL/Python surface mapped via grep + live-schema read (the authoritative path for this SQL-only surface).

Goals (v1 — the pre-cutover schema slice):

  • Replace content_citations with a polymorphic citations table whose cited side can be a content_item or a q_a_pair, enforced by a one-and-only-one CHECK.
  • Stamp the cited target’s revision (an integer version) on every citation at cite time.
  • Generalise the citing side as form_response via a citing_entity_kind enum.
  • Preserve the four win-rate RPCs, the citation_count trigger, and the effectiveness read path against the new shape — no analytics regression.
  • Carry the existing copy-vs-reference semantics (citation_type ∈ {reference, copied, adapted, inspired}) — this is the data capture that unblocks bl-136.

Non-goals (explicitly OUT of this PRODUCT’s v1 scope):

  • The bl-136 “what was copied where” UI surface (copy-provenance panel / filters) — a separate post-cutover feature. Only its data capture is in scope here.
  • Landing the bl-74 q_a_pair_history lineage columns — a consumed dependency owned by the ID-45 pre-promotion lane ({64.8} G3), NOT re-owned here (§ Inv-15).
  • Any new citing kind beyond form_response (the enum is baked, but only form_response is valid in v1).
  • Backfill / data migration — the table is at 0 rows, so REPLACE is a clean CREATE/DROP.

Carried open-question dispositions (this PRODUCT decides)

Section titled “Carried open-question dispositions (this PRODUCT decides)”

The {58.1} recommendations are adopted as the default; ratified here pending Liam sign-off:

  • OQ-58-2 (q_a_pair cited kind in v1) → YES, include now. The q_a_pair enum value and the cited_q_a_pair_id column ship in the v1 schema slice (it is the whole point of “polymorphic”, and the §6.2 handover policy wants the structural shape complete pre-cutover). Its use is gated on bl-74 having landed (§ Inv-15) — the column may ship structurally present but dormant until bl-74 lands. Liam call retained on ship-dormant vs defer-the-column.
  • OQ-58-3 (bake citing_entity_kind enum now) → YES. The citing side is modelled as citing_entity_kind ENUM ('form_response') from v1, not a bare single FK, so bl-136 can later add citing kinds with one enum value rather than a table reshape (§ Inv-7).
  • OQ-58-4 (TS consumer re-point timing) → SAME-PR (recommended). The 5 TS call sites re-point in the same migration PR as the DDL, because the old table is dropped — a fast-follow would leave the writer/reader/MCP-tool/eval-fixture sites referencing a dropped table between PRs (a broken-build window). The {58.4} PLAN may slice the re-point into its own Subtask, but it lands before the DROP is released. Liam call retained if a phased rollout is preferred.

The “user” of this surface is the code that reads and writes citations: the bid-draft writer (draft-stream/route.ts), the record_citation MCP tool (the LLM invoking it), the effectiveness reader, the four win-rate RPCs, and the citation_count trigger. Each invariant below is testable against the new citations table and maps to a slice of the {58.1} pre-cutover DDL.

Cite-target kinds and the one-of constraint

Section titled “Cite-target kinds and the one-of constraint”
  1. A citation records exactly one cited target, of a declared kind. Every citation row carries a cited_kind of type cited_target_kind (an enum) whose value is exactly one of content_item or q_a_pair. There is no “uncited” citation and no citation that cites two targets at once. (DDL slice: cited_target_kind enum + cited_kind NOT NULL column.)

  2. The cited target column matches the declared kind, and exactly one is populated. A citation carries one nullable FK column per cited kind — cited_content_item_id and cited_q_a_pair_id. A one-and-only-one CHECK holds at all times:

    • cited_kind = 'content_item'cited_content_item_id IS NOT NULL and cited_q_a_pair_id IS NULL.
    • cited_kind = 'q_a_pair'cited_q_a_pair_id IS NOT NULL and cited_content_item_id IS NULL. Any row violating this (both null, both set, or kind/column mismatch) is rejected at write time. (DDL slice: per-kind nullable FK columns + the one-of CHECK.)
  3. Each cited FK has real referential integrity. cited_content_item_id references content_items(id) and cited_q_a_pair_id references q_a_pairs(id), each ON DELETE CASCADE. Deleting a content_item or a q_a_pair removes its citations (so the citation count stays honest); there are no silent orphans of the matched_content_ids uuid[] class that ID-93 §4 flagged. (DDL slice: per-kind FK constraints with CASCADE.)

  4. The cite-target set is extensible by ADD COLUMN. Adding a future cited kind (e.g. a KG node or reference_item) is a forward-only migration: a new enum value on cited_target_kind, a new cited_<x>_id nullable FK column, and an extended one-of CHECK. No existing citation row changes, and v1 ships only content_item and q_a_pair. (This is the documented extension recipe for {58.3} TECH; no v1 DDL beyond the two kinds.)

  1. A citation records exactly one citing source, of a declared kind. Every citation row carries a citing_kind of type citing_entity_kind (an enum). In v1 the only valid value is form_response. (DDL slice: citing_entity_kind enum + citing_kind NOT NULL DEFAULT 'form_response' column.)

  2. The citing FK targets form_responses, with integrity and CASCADE. The citing source is recorded via citing_form_response_id referencing form_responses(id) ON DELETE CASCADE. A CHECK holds: citing_kind = 'form_response'citing_form_response_id IS NOT NULL. Because ID-58 sequences AFTER ID-64 {64.14}, this FK targets the renamed form_responses table directly — never bid_responses. (DDL slice: citing FK + citing one-of CHECK.)

  3. The citing side is generalisable without a reshape. Because the citing source is modelled via the citing_entity_kind enum (not a bare single FK), a future citing kind (bl-136 copy-from a q_a_pair or workspace artefact) is added the same way as a cited kind: one enum value + one citing_<x>_id FK column + an extended citing CHECK. v1 emits and accepts only form_response. (Future-proofing per OQ-58-3; no v1 DDL beyond the one kind.)

  1. A citation declares why it exists via citation_type. Every citation carries a citation_type whose value is one of reference, copied, adapted, or inspired, defaulting to reference. This is the same vocabulary the current content_citations table enforces; it is preserved verbatim. (DDL slice: citation_type column + its value CHECK + default.)

  2. reference is the plain-citation default; copied / adapted / inspired mark copy-events. A reference records that the citing artefact referred to the cited target. A copied / adapted / inspired records that content was carried across from the cited target into the citing artefact (verbatim, edited, or as inspiration respectively). This distinction is the data capture that the bl-136 “what was copied where” feature reads — and it is the only bl-136-related work in this PRODUCT’s v1 scope. The bl-136 UI that surfaces these copy-events is a separate post-cutover feature.

  3. The default writer path records reference. The existing bid-draft writer (draft-stream/route.ts), which after saving a draft response inserts one citation per matched content item, continues to record citation_type = 'reference' for those automatic match-citations. Copy-events (copied / adapted / inspired) are recorded by the copy/paste write path, which sets the appropriate type. (Behaviour preserved from the current writer; copy-event path is the bl-136 data-capture half folded into T11.)

  1. Every citation stamps the cited target’s revision at cite time. When a citation is written, it captures the current integer version of the cited target and stores it inline on the citation row — cited_version for the content_item path, cited_q_a_pair_version for the q_a_pair path. The stored version is the revision that was cited, and it does not change when the cited target is later edited. (DDL slice: cited_version integer + cited_q_a_pair_version integer columns; capture at the write site.)

  2. The version pointer matches the populated cited kind. For a content_item-cited row, cited_version is populated (from the content_items / content_history.version integer at cite time) and cited_q_a_pair_version is unused; for a q_a_pair-cited row, cited_q_a_pair_version is populated (from q_a_pairs.version) and cited_version is unused. The version columns are denormalised pointers, not FKs to a history-row id — the integer (cited_id, version) pair resolves the exact content_history / q_a_pair_history snapshot. (DDL slice: the two version columns, per-kind population at the write site.)

  3. The stamped version survives target edits and (where applicable) deletion. Editing a cited content_item to a new version leaves every prior citation’s cited_version untouched, so “this bid cited v3” remains answerable after the item reaches v4. The integer pointer is independent of whether the corresponding history row is later pruned. (Behaviour guarantee on the version columns; verifiable by editing a target and re-reading historic citations.)

  1. content_items.citation_count stays correct under the new shape. The update_citation_count() trigger continues to maintain content_items.citation_count, but now counts only citations whose cited_kind = 'content_item', keyed on cited_content_item_id (the one-line equivalent of today’s content_item_id). Inserting a content_item-cited citation increments the count; deleting one (directly or via CASCADE when the item or the form_response is removed) decrements it. Citations whose cited_kind = 'q_a_pair' do not affect content_items.citation_count. (DDL slice: re-pointed update_citation_count() trigger fn + its INSERT/DELETE trigger bindings.)
  1. The q_a_pair cite path requires the bl-74 lineage columns to exist first. A q_a_pair-cited citation stamping cited_q_a_pair_version = N is only fully auditable if the q_a_pair_history snapshot for version N carries the bl-74 lineage fields (superseded_by, source_workspace_id). The citations table does not land those columns — that is the ID-45 pre-promotion lane’s job ({64.8} G3), because q_a_pair_history is append-only and landing the columns late loses lineage forever. The q_a_pair cited-kind column may ship structurally present in v1, but its use (writing q_a_pair-cited rows in production) MUST NOT precede bl-74 landing. If that ordering slips, T11 escalates rather than silently exercising the dormant path. (No DDL here — a hard pre-req assertion recorded for {58.3} TECH and the Orchestrator.)
  1. get_aggregate_win_rate_stats() preserves its output contract. After the replace, this RPC reads the new citations table instead of content_citations, filtering WHERE cited_kind = 'content_item' and joining cited_content_item_id (cited) → citing_form_response_id (citing) → form-response outcome. Its returned aggregate win-rate shape is unchanged from the caller’s perspective. (DDL slice: re-pointed RPC.)

  2. get_content_win_rate(p_content_item_id) preserves its output contract. This RPC reads citations WHERE cited_kind = 'content_item' AND cited_content_item_id = p_content_item_id, joins to the citing form_response outcome, and returns the same per-item win-rate shape as today. Its parameter and return shape are unchanged. (DDL slice: re-pointed RPC.)

  3. hybrid_search(...) preserves its citation-derived ranking contract. Wherever hybrid_search reads content_citations to factor citation/win-rate signal into ranking, it reads the new citations table under the same content_item-cited × form_response-citing path, with no change to its result ordering contract for equivalent data. (DDL slice: re-pointed RPC.)

  4. search_for_bid_response(...) preserves its citation-join contract. This RPC (itself renamed by {64.14}) reads the new citations table under the content_item-cited × form_response-citing path, returning the same shape as today for equivalent data. (DDL slice: re-pointed RPC; note the RPC name is owned by {64.14}, its citation-join body by T11.)

  5. The effectiveness read path preserves “which form responses cited this item”. The effectiveness reader (app/api/items/[id]/effectiveness/route.ts) lists the citing artefacts for a content item by reading the new citations table (cited_kind = 'content_item', joining through citing_form_response_id to the form-response → question → workspace embed), returning the same logical list as today. (Consumer re-point; same-PR per OQ-58-4.)

  1. A citing artefact does not duplicate a citation of the same cited target. The current UNIQUE(content_item_id, bid_response_id) dedup key is preserved per cited kind via partial-unique indexes — e.g. UNIQUE (citing_form_response_id, cited_content_item_id) WHERE cited_kind = 'content_item' and the equivalent for the q_a_pair path. Re-citing the same target from the same form response is an upsert (idempotent), not a second row. (DDL slice: per-kind partial-unique indexes; preserves the record_citation MCP tool’s onConflict upsert semantics, re-pointed to the new columns.)

  2. The record_citation MCP tool returns the citation it wrote. The tool continues to upsert a citation and select back the written row’s identity and shape, re-pointed to the new columns (id, cited_kind, the populated cited_<x>_id, citing_kind, citing_form_response_id, citation_type, and the populated version column). The LLM invoking the tool receives confirmation of what was cited, of which kind, at which version. (Consumer re-point; the tool’s external contract is preserved modulo the new polymorphic fields.)

  1. RLS roles are preserved on the new table. SELECT is available to any authenticated role; INSERT/UPDATE to editor + admin; DELETE to admin — the same role matrix the current content_citations policies enforce. (DDL slice: RLS policies on citations.)

  2. No anon grant survives the replace (REVOKE-from-anon hygiene). The current table carries a GRANT ALL … TO anon (pre_squash :8289). The new citations table MUST NOT grant any privilege to anon; the replace migration explicitly REVOKEs anon access, per the CLAUDE.md function-grant gotcha. A post-migration check confirms anon holds no privilege on citations. (DDL slice: REVOKE … FROM anon on the new table.)

  3. The old content_citations table is dropped, with no dangling consumer. After the replace, content_citations no longer exists; every consumer (the writer, the MCP tool, the effectiveness reader, the two eval fixtures, the trigger, and the four RPCs) reads or writes citations. No code path references the dropped table. (DDL slice: DROP content_citations, last in the migration after all consumers re-point; TS re-point same-PR per OQ-58-4.)

Behaviour-to-DDL-slice map (for {58.3} TECH)

Section titled “Behaviour-to-DDL-slice map (for {58.3} TECH)”

Each invariant above tags its {58.1} pre-cutover DDL slice inline. Consolidated, the slice is: the new citations table + 2 enums (cited_target_kind, citing_entity_kind) + per-kind one-of CHECKs (cited and citing) + the two version columns + the citation_type CHECK + partial-unique dedup indexes + per-kind indexes + re-pointed update_citation_count() trigger + re-pointed 4 RPCs (get_aggregate_win_rate_stats, get_content_win_rate, hybrid_search, search_for_bid_response) + RLS policies + REVOKE-from-anon + DROP content_citations; plus the same-PR TS re-point of the 5 consumer sites. The bl-74 columns (Inv-15) are a consumed dependency, NOT part of this slice.

  • OQ-58-2 residual: ship the q_a_pair cited column/enum dormant in v1 (structure present, use gated on bl-74) vs defer the column to a later ADD COLUMN. PRODUCT recommends ship-dormant (Inv-2, Inv-15).
  • OQ-58-4 residual: confirm the TS consumer re-point lands same-PR as the DDL (PRODUCT default, Inv-25) vs a phased rollout. Affects {58.4} PLAN decomposition.
  • Inv-15 ordering confirmation: confirm {64.8} G3 / the ID-45 promotion lane still owns the bl-74 DDL, so T11 only asserts the dependency. If that ownership has moved, T11 needs re-briefing before the q_a_pair path is exercised.