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_citationswith a newcitationstable; per-target nullable discriminated-FK polymorphism; inline integer version-on-cite;form_responseciting side via aciting_entity_kindenum; 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).
Summary
Section titled “Summary”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.
Problem
Section titled “Problem”Three structural defects in the current content_citations table block the v1 knowledge
surface:
- Fixed cited side. A citation can only ever point at a
content_item. The bid-draft and copy-tracking surfaces increasingly need to cite aq_a_pair(a promoted Q&A pair) as well — there is no column, enum, or discriminator for it today. - 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”.
bid_response-coupled naming. The citing FK targetsbid_responses, which ID-64 {64.14} renames toform_responses. The forward-referencedciting_entityenum value that would generalise the citing side does not exist yet (only a forward-comment in20260601180058_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 / Non-goals
Section titled “Goals / Non-goals”Goals (v1 — the pre-cutover schema slice):
- Replace
content_citationswith a polymorphiccitationstable whose cited side can be acontent_itemor aq_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_responsevia aciting_entity_kindenum. - Preserve the four win-rate RPCs, the
citation_counttrigger, 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_historylineage 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 onlyform_responseis 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_pairenum value and thecited_q_a_pair_idcolumn 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_kindenum now) → YES. The citing side is modelled asciting_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.
Behavior
Section titled “Behavior”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”-
A citation records exactly one cited target, of a declared kind. Every citation row carries a
cited_kindof typecited_target_kind(an enum) whose value is exactly one ofcontent_itemorq_a_pair. There is no “uncited” citation and no citation that cites two targets at once. (DDL slice:cited_target_kindenum +cited_kind NOT NULLcolumn.) -
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_idandcited_q_a_pair_id. A one-and-only-one CHECK holds at all times:cited_kind = 'content_item'⇒cited_content_item_idIS NOT NULL andcited_q_a_pair_idIS NULL.cited_kind = 'q_a_pair'⇒cited_q_a_pair_idIS NOT NULL andcited_content_item_idIS 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.)
-
Each cited FK has real referential integrity.
cited_content_item_idreferencescontent_items(id)andcited_q_a_pair_idreferencesq_a_pairs(id), eachON DELETE CASCADE. Deleting acontent_itemor aq_a_pairremoves its citations (so the citation count stays honest); there are no silent orphans of thematched_content_ids uuid[]class that ID-93 §4 flagged. (DDL slice: per-kind FK constraints with CASCADE.) -
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 oncited_target_kind, a newcited_<x>_idnullable FK column, and an extended one-of CHECK. No existing citation row changes, and v1 ships onlycontent_itemandq_a_pair. (This is the documented extension recipe for {58.3} TECH; no v1 DDL beyond the two kinds.)
Citing side
Section titled “Citing side”-
A citation records exactly one citing source, of a declared kind. Every citation row carries a
citing_kindof typeciting_entity_kind(an enum). In v1 the only valid value isform_response. (DDL slice:citing_entity_kindenum +citing_kind NOT NULL DEFAULT 'form_response'column.) -
The citing FK targets
form_responses, with integrity and CASCADE. The citing source is recorded viaciting_form_response_idreferencingform_responses(id)ON DELETE CASCADE. A CHECK holds:citing_kind = 'form_response'⇒citing_form_response_idIS NOT NULL. Because ID-58 sequences AFTER ID-64 {64.14}, this FK targets the renamedform_responsestable directly — neverbid_responses. (DDL slice: citing FK + citing one-of CHECK.) -
The citing side is generalisable without a reshape. Because the citing source is modelled via the
citing_entity_kindenum (not a bare single FK), a future citing kind (bl-136 copy-from aq_a_pairor workspace artefact) is added the same way as a cited kind: one enum value + oneciting_<x>_idFK column + an extended citing CHECK. v1 emits and accepts onlyform_response. (Future-proofing per OQ-58-3; no v1 DDL beyond the one kind.)
Copy-vs-reference semantics
Section titled “Copy-vs-reference semantics”-
A citation declares why it exists via
citation_type. Every citation carries acitation_typewhose value is one ofreference,copied,adapted, orinspired, defaulting toreference. This is the same vocabulary the currentcontent_citationstable enforces; it is preserved verbatim. (DDL slice:citation_typecolumn + its value CHECK + default.) -
referenceis the plain-citation default;copied/adapted/inspiredmark copy-events. Areferencerecords that the citing artefact referred to the cited target. Acopied/adapted/inspiredrecords 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. -
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 recordcitation_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.)
Version-on-cite
Section titled “Version-on-cite”-
Every citation stamps the cited target’s revision at cite time. When a citation is written, it captures the current integer
versionof the cited target and stores it inline on the citation row —cited_versionfor thecontent_itempath,cited_q_a_pair_versionfor theq_a_pairpath. 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 integercolumns; capture at the write site.) -
The version pointer matches the populated cited kind. For a
content_item-cited row,cited_versionis populated (from thecontent_items/content_history.versioninteger at cite time) andcited_q_a_pair_versionis unused; for aq_a_pair-cited row,cited_q_a_pair_versionis populated (fromq_a_pairs.version) andcited_versionis unused. The version columns are denormalised pointers, not FKs to a history-row id — the integer(cited_id, version)pair resolves the exactcontent_history/q_a_pair_historysnapshot. (DDL slice: the two version columns, per-kind population at the write site.) -
The stamped version survives target edits and (where applicable) deletion. Editing a cited
content_itemto a new version leaves every prior citation’scited_versionuntouched, 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.)
Citation-count trigger
Section titled “Citation-count trigger”content_items.citation_countstays correct under the new shape. Theupdate_citation_count()trigger continues to maintaincontent_items.citation_count, but now counts only citations whosecited_kind = 'content_item', keyed oncited_content_item_id(the one-line equivalent of today’scontent_item_id). Inserting acontent_item-cited citation increments the count; deleting one (directly or via CASCADE when the item or the form_response is removed) decrements it. Citations whosecited_kind = 'q_a_pair'do not affectcontent_items.citation_count. (DDL slice: re-pointedupdate_citation_count()trigger fn + its INSERT/DELETE trigger bindings.)
bl-74 dependency gate (q_a_pair path)
Section titled “bl-74 dependency gate (q_a_pair path)”- The
q_a_paircite path requires the bl-74 lineage columns to exist first. Aq_a_pair-cited citation stampingcited_q_a_pair_version = Nis only fully auditable if theq_a_pair_historysnapshot for version N carries the bl-74 lineage fields (superseded_by,source_workspace_id). Thecitationstable does not land those columns — that is the ID-45 pre-promotion lane’s job ({64.8} G3), becauseq_a_pair_historyis append-only and landing the columns late loses lineage forever. Theq_a_paircited-kind column may ship structurally present in v1, but its use (writingq_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.)
Win-rate RPC contracts (post-replace)
Section titled “Win-rate RPC contracts (post-replace)”-
get_aggregate_win_rate_stats()preserves its output contract. After the replace, this RPC reads the newcitationstable instead ofcontent_citations, filteringWHERE cited_kind = 'content_item'and joiningcited_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.) -
get_content_win_rate(p_content_item_id)preserves its output contract. This RPC readscitations WHERE cited_kind = 'content_item' AND cited_content_item_id = p_content_item_id, joins to the citingform_responseoutcome, and returns the same per-item win-rate shape as today. Its parameter and return shape are unchanged. (DDL slice: re-pointed RPC.) -
hybrid_search(...)preserves its citation-derived ranking contract. Whereverhybrid_searchreadscontent_citationsto factor citation/win-rate signal into ranking, it reads the newcitationstable under the samecontent_item-cited ×form_response-citing path, with no change to its result ordering contract for equivalent data. (DDL slice: re-pointed RPC.) -
search_for_bid_response(...)preserves its citation-join contract. This RPC (itself renamed by {64.14}) reads the newcitationstable under thecontent_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.) -
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 newcitationstable (cited_kind = 'content_item', joining throughciting_form_response_idto the form-response → question → workspace embed), returning the same logical list as today. (Consumer re-point; same-PR per OQ-58-4.)
Dedup and write-path invariants
Section titled “Dedup and write-path invariants”-
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 theq_a_pairpath. 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 therecord_citationMCP tool’sonConflictupsert semantics, re-pointed to the new columns.) -
The
record_citationMCP 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 populatedcited_<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.)
Access control and hygiene
Section titled “Access control and hygiene”-
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_citationspolicies enforce. (DDL slice: RLS policies oncitations.) -
No anon grant survives the replace (REVOKE-from-anon hygiene). The current table carries a
GRANT ALL … TO anon(pre_squash:8289). The newcitationstable MUST NOT grant any privilege toanon; the replace migration explicitlyREVOKEs anon access, per the CLAUDE.md function-grant gotcha. A post-migration check confirmsanonholds no privilege oncitations. (DDL slice: REVOKE … FROM anon on the new table.) -
The old
content_citationstable is dropped, with no dangling consumer. After the replace,content_citationsno longer exists; every consumer (the writer, the MCP tool, the effectiveness reader, the two eval fixtures, the trigger, and the four RPCs) reads or writescitations. 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.
Open questions for Liam
Section titled “Open questions for Liam”- OQ-58-2 residual: ship the
q_a_paircited 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.