ID-58 {58.4} PLAN — Citations polymorphic cite-target + version-on-cite (T11)
ID-58 {58.4} PLAN — Citations polymorphic cite-target + version-on-cite
Section titled “ID-58 {58.4} PLAN — Citations polymorphic cite-target + version-on-cite”Status: PLAN draft — authored 09/06/2026 (Session 330) by a FRESH planner instance (Q-PLANNER-2: NOT the {58.1}/{58.2}/{58.3} author). Reads the ratified {58.2} PRODUCT (25 invariants Inv-1..25) and {58.3} TECH (25 proposed-changes, 1:1) in full, plus the {58.1} RESEARCH. Decomposes the ratified DDL slice + consumer re-point into {58.5+} implementation Subtasks. Folds the four S330 Orchestrator-ratified decisions (below).
No DDL applied here — spec/decomposition only. The migration is authored + applied by {58.5} via the Supabase CLI, staging-first; prod push is a Liam gate.
Overview
Section titled “Overview”Replace the fixed content_items × bid_responses junction content_citations (0 rows,
staging + prod) with a polymorphic citations table: per-target discriminated nullable
FK columns + a cited_target_kind enum + one-of CHECK; a citing_entity_kind enum
(form_response only valid v1); inline integer version-on-cite per kind; NEW native
Anthropic citation-span columns (S330 decision #1); per-kind partial-unique dedup indexes;
RLS + REVOKE-from-anon. Re-point the update_citation_count() trigger + 4 win-rate RPCs +
8 TS/Python consumer sites (the TECH.md 5 + 3 additional sites this PLAN’s grep sweep
surfaced — see §Consumer surface correction). DROP content_citations last. All in one
migration PR (Inv-25 / OQ-58-4 SAME-PR).
Code-intelligence orientation (recorded, S330 09/06/2026). Both code-intel tools
UNAVAILABLE at planning time, consistent with {58.1}/{58.3}: gitnexus LadybugDB
version 41 vs build 40 (index mid-rebuild, confirmed via the PreToolUse hook returning
only 5 fuzzy draft* symbols — no citation/citations/cite_content symbol resolved);
ccc search "citations win-rate trigger RPC" errored (cocoindex-code daemon traceback).
This is in any case the authoritative path for this SQL-heavy surface: ast-dataflow/
gitnexus do not index SQL migrations or Python (per .ast-dataflow/CLAUDE.md), and the
citations surface is 1 table + 1 trigger fn + 4 RPCs (SQL) + a handful of TS/Python
.from('content_citations') sites. The consumer surface was therefore (re-)mapped by
direct grep -rn "content_citations" app/ lib/ scripts/ + file reads + migration reads —
the authoritative path. gitnexus orientation: no live symbol index available (LadybugDB v41/build v40; ccc daemon down) — SQL/Python/TS surface mapped via grep + file/migration read. The {58.5+} implementation Subtasks each carry a code-intel discipline block: the
SQL/Python sites use grep + migration-read scope containment (git diff --name-only is the
authoritative scope fallback in worktrees per CLAUDE.md gitnexus_detect_changes()
gotcha); the TS sites (writer, MCP tool, effectiveness reader) run ast-dataflow callers /
references + gitnexus_impact (if the index recovers) before editing the symbols they
touch.
Architecture decisions (the four S330 ratifications folded in)
Section titled “Architecture decisions (the four S330 ratifications folded in)”D-S330-1 — Citations-span columns (NEW; Liam ratified 09/06/2026). The native Anthropic
citation spans that lib/ai/draft.ts already extracts via
lib/citations.ts:extractCitedResponse (→ CitationEntry in
types/procurement-metadata.ts:7-15: cited_text, source_index (= Anthropic
search_result_index), start_block_index, end_block_index) currently persist only
in bid_responses.metadata.citations_data JSONB. The relational table writes 1 row per
matched content item (route.ts:286 maps matchedContent), NOT 1 row per Anthropic
citation — a lossy FK+version projection. ADD to the citations DDL slice four nullable
span columns: cited_text text, cited_location_kind text CHECK
('block'|'char'|'page'), cited_start int, cited_end int. The draft-stream writer
({58.6}) maps EACH CitationEntry → one citations row: source_index →
matchedContent[idx].id → cited_content_item_id (the mapping lib/citations.ts:63
already resolves), cited_text → cited_text, cited_location_kind='block',
start_block_index → cited_start, end_block_index → cited_end. This makes citations
the authoritative span store, not a lossy projection. The matched-content rows that
yield no Anthropic citation are still recorded (citation_type='reference', span columns
NULL) so win-rate cardinality is preserved (citation_count counts matched items, not
spans — see §Cardinality note). A TECH.md ADDENDUM block (“PC-26 / R3 — span-capture
columns + per-citation writer cardinality”) is authored same-PR by {58.6} to keep the spec
1:1 and source-of-truth.
D-S330-2 — OQ-58-6 = RENAME the cite_content arg. The LLM-facing inputSchema arg
bid_response_id → form_response_id (lib/mcp/tools/procurement.ts:425), same PR. The
tool name cite_content is UNCHANGED (preserves mcp-fixture-sync.test.ts TOOL_COUNT
/ CANONICAL_TOOL_NAMES). The rename propagates to the 2 eval invocation sites
(fixtures.ts:641, functional-correctness.ts:2045) — owned by {58.9}.
D-S330-3 — HARD GATE on {64.14}. ID-58’s migration ({58.5}) CANNOT apply until
ID-64 {64.14} (bid_responses → form_responses table rename) is done:
citing_form_response_id FKs form_responses, which does not exist yet (verified
09/06/2026: ID-64 in_progress, {64.14} = pending; no form_responses table in the
migration corpus). {58.5}‘s first action is a gate-confirm of {64.14} done; if not done,
{58.5} STOPS and escalates (do NOT stop-gap target bid_responses). This is a cross-Task
dependency expressed at the Task level (ID-58 → ID-64); the Subtask records below carry it
as a crossTaskGate note on {58.5}, NOT as a sibling Subtask dependency (sibling-only
rule).
D-S330-4 — q_a_pair path ships DORMANT (D1). The q_a_pair cited column/enum/version
column/index all ship structurally present in v1 but UNUSED. Their use (writing
q_a_pair-cited rows in production) is gated on bl-74 (the q_a_pair_history
superseded_by + source_workspace_id lineage columns) landing first, via the ID-45
pre-promotion lane {64.8} G3 — NOT re-owned here (Inv-15). T11 only asserts the dependency.
This is a second cross-Task gate (bl-74), again Task-level, carried as a dormantGate
note, not a sibling dep.
D-arch — Version substrate (R1, from {58.3}). There is NO content_items.version /
q_a_pairs.version column. The cite-time version is
COALESCE((SELECT MAX(version) FROM content_history WHERE content_item_id = X), 0) for the
content_item path (verified: pre_squash:155 auto_version_content_history keys on
MAX(version) FROM content_history). The writer ({58.6}) reads the history MAX, not a
column. The dormant q_a_pair version read (MAX(version) FROM q_a_pair_history) is deferred
with D1.
Consumer surface correction (load-bearing — PLAN grep sweep, 09/06/2026)
Section titled “Consumer surface correction (load-bearing — PLAN grep sweep, 09/06/2026)”TECH.md §Context lists 5 TS consumer sites. A fresh
grep -rn "content_citations" app/ lib/ scripts/ surfaced 3 additional sites + 2
comment-only references that the DROP (Inv-25 “no dangling consumer”) MUST clear:
| Site | Kind | Re-point | Owner |
|---|---|---|---|
app/api/procurement/[id]/responses/draft-stream/route.ts:286-306 | writer (real) | per-citation cardinality + spans + version | {58.6} |
lib/mcp/tools/procurement.ts:419-490 | MCP cite_content (real) | columns + arg rename + formatter | {58.7} |
lib/mcp/formatters/procurements.ts:170-188 | CitationResult/formatCitation | field rename | {58.7} |
app/api/items/[id]/effectiveness/route.ts:97-117 | effectiveness reader (real) | columns + embed alias | {58.8} |
scripts/mcp-eval/fixtures.ts:478, 641 | eval delete + cite_content arg (real) | columns + arg rename | {58.9} |
scripts/mcp-eval/functional-correctness.ts:2045, 2276 | eval cite_content arg + cleanup delete (real) | columns + arg rename | {58.9} |
scripts/export-user-data.ts:514, 715, 738, 760 | GDPR export (real, NEW) | .from('citations') + type field rename | {58.10} |
scripts/cleanup-stale-test-artifacts.ts:185 | test cleanup tuple (real, NEW) | ['citations','cited_content_item_id'] | {58.10} |
scripts/wipe-procurement-responses.ts:358 | comment only (NEW) | comment text → citations | {58.10} |
lib/quality/quality-score.ts:29 | comment only (NEW) | comment text → citations | {58.10} |
NOTE on functional-correctness.ts:2276: TECH.md described this as “insert a test citation
row”; the verified reality is a .delete().eq('content_item_id', id) cleanup. The re-point
is mechanical either way (.from('citations').delete().eq('cited_content_item_id', id)).
Cardinality note (resolves Inv-14 vs D-S330-1 interaction)
Section titled “Cardinality note (resolves Inv-14 vs D-S330-1 interaction)”The OLD writer wrote 1 row per matched item. D-S330-1’s writer writes 1 row per Anthropic
CitationEntry, PLUS retains 1 reference row per matched item that produced no
Anthropic citation (so win-rate coverage is not lost when the LLM cites only a subset). The
per-kind partial-unique index UNIQUE (citing_form_response_id, cited_content_item_id) WHERE cited_kind='content_item' means a single (form_response, content_item) pair is ONE
row — so multiple Anthropic spans against the same content item COLLAPSE to one row via
upsert, keeping the LAST span (or the writer aggregates spans before insert). {58.6} MUST
resolve this explicitly (the {58.6} brief specifies: upsert per (form_response,
content_item); if multiple CitationEntry resolve to the same content item, write the
first/primary span and the row still counts once for citation_count). citation_count
(Inv-14) therefore still counts DISTINCT cited content items per the partial-unique index —
unchanged semantics. The span columns are best-effort capture of the primary span per
cited-item row; full multi-span fidelity remains in JSONB. This keeps Inv-14 and the 4 RPCs
(which COUNT(DISTINCT cited_content_item_id)) regression-free.
Dependency graph
Section titled “Dependency graph”{58.5} DDL migration (staging) ← cross-Task GATE: {64.14} done (D-S330-3) │ (serial root — owns the ONE migration file; all TS/Python re-points depend on type regen post-apply) │ ├── {58.6} writer re-point + spans + version + TECH ADDENDUM (route.ts, draft-stream) ├── {58.7} cite_content MCP tool + arg rename + formatter (procurement.ts, procurements.ts) ├── {58.8} effectiveness reader re-point (effectiveness/route.ts) ├── {58.10} 3 script consumers + 2 comments (export-user-data, cleanup-stale, wipe, quality-score) │ └── {58.9} mcp-eval fixtures + functional-correctness (depends 58.5 + 58.7 — consumes the arg rename) │ ▼ {58.11} DROP content_citations + grep-clean + full regression gate (depends 58.6,58.7,58.8,58.9,58.10) │ ▼ {58.12} staging verification + RPC/trigger behaviour + Liam prod-push handoff (depends 58.11)Wave plan (parallel-vs-serial):
- Wave 1 (serial root): {58.5} alone. Owns the single migration file; nothing else can re-point until types regenerate against the applied staging schema. Gate-confirms {64.14} first.
- Wave 2 (parallel — disjoint file ownership): {58.6}, {58.7}, {58.8}, {58.10} all depend only on {58.5} and touch DISJOINT files (writer route / MCP tool+formatter / effectiveness route / scripts). Dispatch concurrently (worktree-isolated, cherry-pick).
- Wave 3 (serial after 58.7): {58.9} (mcp-eval) consumes the {58.7} arg rename
(
form_response_id), so it depends on {58.7} as well as {58.5}. Run after Wave 2’s 58.7 lands. (Could parallelize with the tail of Wave 2 if 58.7 finishes first.) - Wave 4 (serial): {58.11} DROP + grep-clean +
bun build/bun lint/bun run test— depends on ALL re-points landed (58.6/7/8/9/10), because the DROP must be last (Inv-25). - Wave 5 (serial): {58.12} staging verification + behaviour tests + Liam prod-push gate handoff — depends on {58.11}.
File-ownership conflicts: none within a wave. {58.7} owns both procurement.ts and
procurements.ts (tool + its formatter) so they cannot race. {58.9} owns both mcp-eval
files. {58.10} owns all 4 script/comment sites. {58.5} owns the sole migration file and is
the only DDL author (no other subtask runs db push).
Risks and mitigations
Section titled “Risks and mitigations”| Risk | Impact | Mitigation |
|---|---|---|
| {64.14} not done at {58.5} apply | High | D-S330-3 hard gate: {58.5} gate-confirms + STOPS/escalates; never targets bid_responses |
| RPC RETURNS drift breaks CREATE OR REPLACE | High | {58.5} re-points against authoritative latest bodies (id84 20260606194939; id197 20260530121355), preserves RETURNS byte-for-byte ({58.3} R3) |
| Span columns balloon row count / break win-rate COUNT(DISTINCT) | Med | Cardinality note: upsert per (form_response, content_item); count stays DISTINCT-item |
| q_a_pair dormant path exercised before bl-74 | Med | D-S330-4 dormantGate: no production q_a_pair write until bl-74; {58.12} asserts |
| Type regen stale at TS re-point | Med | {58.5} regenerates database.types.ts after staging apply, before Wave 2 dispatch |
| project-ref drift to prod | High | {58.5} first action: cat supabase/.temp/project-ref + relink staging (turayklvaunphgbgscat) per CLAUDE.md |
| anon grant survives | Med | Inv-24: explicit REVOKE + {58.12} has_table_privilege('anon',...) assertion; migration-revoke-guard.yml lint |
Open questions (carried to Orchestrator / Liam)
Section titled “Open questions (carried to Orchestrator / Liam)”- TECH ADDENDUM ownership: {58.6} authors the “PC-26 / R3” ADDENDUM block into TECH.md same-PR (per D-S330-1). Confirm the Orchestrator wants the span columns folded into the existing TECH.md as an addendum (recommended — keeps 1:1) rather than a new PC-26 invariant in PRODUCT.md (which would break the ratified 25-invariant count).
- Multi-span fidelity: the cardinality note keeps ONE span per cited-item row (primary).
If full multi-span relational fidelity is later wanted, that is a post-v1 ADD (a
citation_spanschild table) — flagged, not scoped here. - Inv-15 / R2 ordering: confirm {64.14} and {64.8} G3 (bl-74) still own their DDL and land before {58.5} / before the q_a_pair path is exercised.
Subtask records — see the {58.5+} TM-shape JSON returned to the Orchestrator
Section titled “Subtask records — see the {58.5+} TM-shape JSON returned to the Orchestrator”The 8 implementation Subtasks ({58.5}–{58.12}) are returned as TM-shape JSON for the
Orchestrator to append to docs/reference/task-list.json. Each carries a load-bearing
details dispatch brief (file paths, line refs, the code-intel discipline block,
spec-slice references PRODUCT Inv-N / TECH PC-N), a pending status, sibling-only
dependencies, and a one-line testStrategy. The {58.5} migration record carries the
{64.14} cross-Task gate note explicitly.