ID-59 {59.19} PRODUCT — Corpus q_a_extractions → q_a_pairs promotion (route i, extracted_from_corpus)
ID-59 {59.19} PRODUCT — Corpus Q&A promotion (route i, origin_kind=extracted_from_corpus)
Section titled “ID-59 {59.19} PRODUCT — Corpus Q&A promotion (route i, origin_kind=extracted_from_corpus)”Status: PRODUCT draft — authored 13/06/2026 by a FRESH planner instance (Q-PLANNER-2). This is a SEPARATE feature from the sibling
PRODUCT.mdin this dir (per-UC write-back +edit_intentcapture); it does NOT overwrite it. Inputs read in full: the {59.19} subtask design grounding (S350 Q&A-coverage trace; Spike-S16 §6 two-tier model), the route-iii promotion endpoint shipped by {59.14} (app/api/q-a-pairs/promote/route.ts), theq_a_pairs/q_a_extractionsschema, theq_a_searchRPC, and the ID-45INV-23definition.GAP-1 (S350): the corpus-wide
q_a_extractions → q_a_pairspromotion (route i) was DESIGNED in S16 (two-tier model: extractions are the derived cache, pairs are the corpus surface) but never BUILT — only route iii (form-responses → pairs, UC5 {59.14} ✅) ships. This PRODUCT specifies the behaviour of route i. The companion{59.20}TECH (write-tech-spec) is the next chain step.
Summary
Section titled “Summary”After the source-driven re-ingest (ID-45) re-walks the full corpus, extracted Q&A lands in the
q_a_extractions derived cache — but Q&A search (q_a_search) only reads q_a_pairs, so extracted
Q&A is INVISIBLE to consumers until promoted. Route i promotes each live (non-invalidated) corpus
q_a_extraction into a q_a_pairs row tagged origin_kind='extracted_from_corpus', with
promoted_to_pair_id linkage written back to the source extraction, idempotently (re-walking the
corpus never duplicates pairs), and drives the promoted pair to a state in which it is visible to
q_a_search — satisfying ID-45 INV-23.
Problem
Section titled “Problem”The platform stores Q&A in a two-tier model (S16 §6): q_a_extractions is a derived,
pipeline-UPSERTed cache (one row per extracted question, FK to the source content_item), and
q_a_pairs is the curated corpus surface that consumers read. The promotion taxonomy has multiple
routes: route i = corpus extractions (extractor_kind ∈ {llm_extraction, markdown_heading_v1, yaml_frontmatter_v1}) → pairs (origin_kind='extracted_from_corpus'); route iii =
form-responses → pairs (origin_kind='derived_from_form_response', UC5 {59.14} ✅). Route i was
designed in S16 §6 (promoted_to_pair_id lineage, origin_kind enum value) and 05-qa-flow §3.1/§9,
but §9’s promotion flow only ever specced route iii. The decision-graph (S231 Q3.4/Q3.5) framed
LLM-extraction as a one-shot migration helper rather than a recurring step, so historical Q&A was
assumed converted once via origin_kind='imported_legacy'. ID-45 changes that assumption: the
re-ingest re-walks the FULL corpus fresh into q_a_extractions on every run, so extraction →
pair promotion must run for the re-ingested corpus, repeatably.
This GAP is a pre-cutover dependency for ID-45 INV-23: until extracted Q&A is promoted to
q_a_pairs in a search-visible state, corpus Q&A is invisible to q_a_search after re-ingest.
Code-intelligence orientation
Section titled “Code-intelligence orientation”gitnexus_query({query: 'q_a_extractions q_a_pairs promotion'}) (repo knowledge-hub,
13/06/2026) returned the route-iii promotion flow plus the Python extraction producers; cited
verbatim below. gitnexus_context({name: 'POST', file_path: 'app/api/q-a-pairs/promote/route.ts'})
confirmed the route-iii call graph. ast-dataflow is TS-only and does not cover the Python pipeline
or SQL migrations, so the q_a_extractions/q_a_search claims are grounded by a grep sweep over
scripts/, supabase/migrations/ plus direct migration reads.
promoted_to_pair_idhas ZERO writers — confirming the GAP.grep -rniE 'promoted_to_pair_id'overlib/ app/ components/ types/ scripts/ supabase/(13/06/2026) returns exactly ONE hit — the schema column definition (supabase/migrations/20260520225456_t6_q_a_pairs_full_schema.sql:120,promoted_to_pair_id uuid NULL REFERENCES public.q_a_pairs(id) ON DELETE SET NULL). No TS, Python, or SQL code WRITES it. Route i is greenfield against a ratified design.q_a_extractionsis read only by Python pipeline tests — no TS reader.grep -rniE 'q_a_extractions'over TS dirs returns onlyscripts/tests/test_cocoindex_*.pymatches (the cocoindex UPSERT-target tests). Confirms the {59.19} grounding: “zero TS readsq_a_extractions”.origin_kind='extracted_from_corpus'is a valid value with ZERO live writers. The CHECK constraintq_a_pairs_origin_kind_check(…t6_q_a_pairs_full_schema.sql:68) admits('extracted_from_corpus', 'curated_explicit', 'derived_from_form_response', 'imported_legacy'). The ONLY liveorigin_kindwriter isapp/api/q-a-pairs/promote/route.ts:159writing'derived_from_form_response'(route iii).'extracted_from_corpus'is admissible but never written — the GAP.- Route-iii promotion shape (the pattern route i mirrors) —
gitnexus_contextverbatim.POSTatapp/api/q-a-pairs/promote/route.ts:70(processproc_97_post) callsgetAuthorisedClient,authFailureResponse,parseBody,tryQuery,coerceIntent,safeErrorMessage(outgoingcalls). It does NOT callarbitrate/arbitrateMany(single-actor). It INSERTs aq_a_pairsrow withorigin_kind='derived_from_form_response',publication_status='draft', lineage (source_form_response_id,source_question_id), via the RLS-scoped authorised client (no service-role escalation). Route i diverges from this shape in three load-bearing ways: (a) it is corpus-batch not per-response; (b) it writespromoted_to_pair_idlineage on the source EXTRACTION, not response/question FKs on the pair; (c) it must reach search-visibility, not stop atdraft— see INV-9/INV-10. q_a_searchvisibility predicate (the INV-23 gate) — verbatim.public.q_a_search(supabase/migrations/20260520231524_t6_q_a_search_rpcs.sql:71) ranks pairsWHERE qap.question_embedding IS NOT NULL AND qap.publication_status = 'published'(lines 117–118). So a promoted pair is search-visible ONLY when BOTHpublication_status='published'ANDquestion_embedding IS NOT NULL. Route iii stops atdraftwith NO embedding — insufficient for INV-23.grep -rniE 'question_embedding'overlib/ app/ scripts/returns NO application or pipeline writer ofq_a_pairs.question_embedding(only the migration/RPC definitions) — route i is the first writer of this column on the pair surface (generateEmbeddinglives atlib/ai/embed).q_a_extractionsshape — the idempotency + invalidation surface.…t6_q_a_pairs_full_schema.sql:103:id,source_content_item_id,extractor_kind,extracted_question_text,extracted_answer_text,extraction_metadata jsonb,promoted_to_pair_id uuid NULL,invalidated_at timestamptz NULL,created_at,updated_at. The schema comment (lines 122–124): “Invalidation audit trail — set by cocoindex when source content changes (§3.1). Invalidated rows are NOT deleted; new extraction rows are emitted for new content.” Soinvalidated_at IS NULLis the live-row predicate, andpromoted_to_pair_id IS NOT NULLis the already-promoted predicate — both load-bearing for idempotency (INV-5).- ID-45
INV-23verbatim (specs/id-45-full-corpus-reingest/PRODUCT.md:257): “Extraction count is plausible vs source QA count … and the UC5 promotionq_a_extractions → q_a_pairssucceeds post-run as a separate assertion. Validation: … post-promotionq_a_pairsrow count > 0 and consistent with promoted extractions.” (INV-23 says “UC5” but means the extraction→pair promotion, i.e. route i — UC5 is route iii of form-responses and does not readq_a_extractions.)
Goals / Non-goals
Section titled “Goals / Non-goals”Goals. Promote live corpus q_a_extractions into q_a_pairs tagged
origin_kind='extracted_from_corpus'; write promoted_to_pair_id lineage back to the source
extraction; make the promotion idempotent so re-walking the corpus never duplicates pairs; drive
each promoted pair to a state visible to q_a_search (publication_status='published' AND
question_embedding IS NOT NULL); satisfy ID-45 INV-23.
Non-goals (v1). Route iii (form-responses → pairs — UC5 {59.14} ✅, separate route). Route ii /
any other taxonomy route. Cross-workspace dedup of promoted pairs (UC8 — separate {59} slice). The
edit_intent arbitration substrate (sibling PRODUCT.md — route-i promotion is single-actor /
batch, does NOT invoke arbitrate()). One-shot legacy migration (origin_kind='imported_legacy' —
historical, already-run). Re-extraction itself (the cocoindex flow OWNS writing q_a_extractions;
route i only READS them). The DDL/migration mechanics (a {59.20} TECH concern); this PRODUCT
specifies the behaviour the promotion enables.
Figma: none provided. The only user-facing surface is the optional curated review queue (INV-13/INV-14); when built it follows Warm Meridian semantic tokens and the existing review-tab pattern.
Behavior
Section titled “Behavior”“User” here is twofold: the operator running the post-re-ingest promotion (and the ID-45
INV-23Checker asserting it succeeded), and the downstream consumer ofq_a_search(MCP / UI) who must find the re-ingested corpus Q&A. Behaviour is specified so both can rely on it.
A. Promotion scope and trigger
Section titled “A. Promotion scope and trigger”-
Promotion source = live corpus extractions only. Route i promotes
q_a_extractionsrows that are (a) NOT invalidated (invalidated_at IS NULL) and (b) NOT already promoted (promoted_to_pair_id IS NULL). An invalidated extraction (its source content changed; cocoindex has superseded it with a fresh row) is NEVER promoted. An already-promoted extraction is NEVER re-promoted (INV-5). Rows with no usable answer text are skipped, not failed (INV-7). -
Promotion reads, never writes, the extraction cache’s identity. Route i is downstream of the cocoindex flow: cocoindex OWNS creating/invalidating
q_a_extractionsrows; route i only READS them and WRITESpromoted_to_pair_idback. Route i MUST NOT create, delete, or re-keyq_a_extractionsrows, and MUST NOT write any source file orcontent_item— promotion is a KH-DB-only operation over the two Q&A tables. -
Trigger — post-re-ingest batch (v1 default). Route i runs as a batch step AFTER an ID-45 re-ingest completes and BEFORE the cutover gate is asserted, walking all live, unpromoted extractions in the corpus in one operation. The batch is operator-invokable (and ID-45-pipeline-invokable) and reports a structured summary: counts of extractions considered, promoted, skipped (with reason), and already-promoted.
- Open question (OQ-59-19-1): auto (post-re-ingest batch, v1 default above) vs curated
(human review queue, draft → in_review → published per INV-13/INV-14). Recommendation: ship the
auto batch for v1 because ID-45
INV-23requires post-run search-visibility without a human in the loop; offer the curated queue as an opt-in/v1.1 affordance layered ON the same promotion primitive. Needs Liam ratification — it determines whether INV-9 drives pairs topublisheddirectly or parks them atin_reviewfor human publish.
- Open question (OQ-59-19-1): auto (post-re-ingest batch, v1 default above) vs curated
(human review queue, draft → in_review → published per INV-13/INV-14). Recommendation: ship the
auto batch for v1 because ID-45
B. Tagging, lineage, and field mapping
Section titled “B. Tagging, lineage, and field mapping”-
origin_kindtagging. Every pair created by route i is taggedorigin_kind='extracted_from_corpus'(a value theq_a_pairs_origin_kind_checkalready admits). Route i MUST NOT write any otherorigin_kind, and MUST NOT mutate theorigin_kindof a pair it did not create. A pair’sorigin_kindis how a consumer (and the Checker) distinguishes corpus-promoted Q&A from form-response-promoted (route iii,derived_from_form_response) or curated (curated_explicit) Q&A. -
promoted_to_pair_idlineage — exactly-once linkage. On promoting an extraction, route i sets that extraction’spromoted_to_pair_idto the id of the newly created pair. The linkage is the idempotency key (INV-5/INV-6): an extraction withpromoted_to_pair_idalready set is treated as already-promoted and skipped. The linkage MUST be written in the same logical operation as the pair insert so a created pair is never left without its back-link (no orphaned pair, no double-promotion window — INV-8). The relationship is one extraction → one pair (1:1); route i does NOT merge multiple extractions into one pair in v1 (dedup is UC8, a non-goal). -
Field mapping. The promoted pair carries:
question_textfrom the extraction’sextracted_question_text;answer_standardfromextracted_answer_text;alternate_question_phrasingsfrom the extraction’s phrasings (carried through the cache per the ID-94.1/G4 path) when present, else the empty default. The pair’ssource_content_item_id-derived provenance (the extraction’ssource_content_item_id) is preserved so a consumer can trace a promoted pair back to its corpus content item. The pair is NOT given form-response FKs (source_form_response_id/source_question_idare route-iii lineage and remain NULL for route-i pairs).
C. Idempotency on re-ingest
Section titled “C. Idempotency on re-ingest”-
Skip, don’t fail, on unpromotable rows. An extraction with no usable answer text (NULL/empty
extracted_answer_text) is SKIPPED with a recorded reason and does not create a pair (mirrors the route-iii 422 “no answer text to promote” guard, applied as a batch skip rather than an HTTP error). Skips are counted and surfaced in the batch summary so the operator/Checker can reconcile “considered vs promoted” without a silent drop. -
Idempotent re-run — no duplicate pairs. Running route i twice over the same corpus state produces the SAME set of
q_a_pairs— the second run finds every still-live extraction already carriespromoted_to_pair_idand promotes nothing new. Re-walking the corpus (a fresh ID-45 re-ingest) MUST NOT duplicate pairs for extractions that were already promoted and remain live. The invariant a consumer relies on: the count ofextracted_from_corpuspairs equals the count of live extractions that have ever been promoted — never more. -
Re-ingest that supersedes an extraction does not orphan its pair silently. When cocoindex invalidates an extraction (
invalidated_atset) and emits a fresh extraction for the changed content, the NEW extraction (invalidated_at IS NULL,promoted_to_pair_id IS NULL) is eligible for promotion and produces a new pair on the next route-i run; the OLD invalidated extraction is never re-promoted (INV-1). The disposition of the pair created from the now-invalidated extraction (supersede vs leave-published) is the lifecycle concern of INV-12.- Open question (OQ-59-19-2): when an extraction is invalidated after its pair was published,
does route i mark the stale pair
archived/superseded_bythe new pair, or leave it published until a separate reconciliation step acts? Recommendation: v1 leaves the stale pair published (no silent corpus loss) and records the supersession lineage where the schema allows (superseded_by); active stale-pair retirement is v1.1 / a reconciliation concern. Needs Liam ratification — it bounds whether route i is purely additive or also retires.
- Open question (OQ-59-19-2): when an extraction is invalidated after its pair was published,
does route i mark the stale pair
D. Search visibility (satisfying ID-45 INV-23)
Section titled “D. Search visibility (satisfying ID-45 INV-23)”-
A promoted pair becomes visible to
q_a_search.q_a_searchreturns a pair ONLY when BOTHpublication_status='published'ANDquestion_embedding IS NOT NULL(verified verbatim from the RPC). Therefore a route-i promotion is not “done” — and ID-45INV-23is not satisfied — until each promoted pair (under the auto-batch trigger, INV-3) reachespublication_status='published'AND has a non-NULLquestion_embedding. A pair sitting atdraftwith no embedding (the route-iii resting state) is NOT search-visible and does NOT satisfy INV-23. -
question_embeddingis populated as part of promotion. Because no existing writer populatesq_a_pairs.question_embedding, route i is responsible for setting it (from the pair’squestion_text, using the platform’s standard 1024-dim embedding, consistent with the valueq_a_searchcosine-compares against). A pair that cannot be embedded (embedding step fails) is NOT silently published as invisible: it is recorded as a promotion failure for that extraction (itspromoted_to_pair_idis still set so it is not re-attempted blindly, but the failure is surfaced in the batch summary so the operator/Checker sees the shortfall) — the platform MUST NOT report INV-23 success while pairs are silently un-embedded.- Open question (OQ-59-19-3): on embedding failure for a row, retry-in-batch vs defer to a re-embed pass. Recommendation: surface-and-continue in v1 (do not abort the whole batch for one embedding failure), with the failure count gating the INV-23 “consistent with promoted extractions” assertion. Needs Liam ratification.
-
Lifecycle transitions are honoured. Under the curated trigger (INV-13), promoted pairs move
draft → in_review → publishedthrough the existing publication-status lifecycle; under the auto trigger (INV-3, v1 default) route i drives them topublisheddirectly (with the embedding set, INV-11). In BOTH cases the pair MUST NOT skip thequestion_embeddingrequirement: a pair reachingpublishedwithout an embedding is a defect, because it would be silently absent fromq_a_searchdespite appearing published. (testStrategyof {59.19}: “lifecycle draft → in_review → published honoured” — satisfied by INV-12; the auto path is the published-direct specialisation.)
E. Roles, scoping, and AI-invisibility
Section titled “E. Roles, scoping, and AI-invisibility”-
Curated review queue (if built — INV-3 OQ disposition). If the curated trigger is chosen, route-i promotions land as
q_a_pairsdrafts in a review surface where an admin/editor reviews and publishes each (or in bulk), mirroring the existing review-tab pattern. The surface uses Warm Meridian semantic tokens, distinguishes states by more than colour (WCAG 2.1 AA), shows an explicit empty state when the queue is empty, and uses UK English (DD/MM/YYYY). This surface is READ-then-publish; it does not let a reviewer edit the extraction cache. -
Authorisation. The promotion operation (batch or per-row publish) is restricted to
admin/editorroles, following the route-iii guard pattern (getAuthorisedClient(['admin', 'editor']), failures viaauthFailureResponse). The batch runs through an authorised/operator context (not anon, not a public route); any HTTP surface is authenticated and NOT added toproxy.tspublicRoutes. Viewers/reviewers cannot promote or publish. -
Workspace scoping. Route i is corpus-level (the corpus is the re-ingest scope); it MUST NOT leak Q&A across a workspace boundary the acting operator/consumer cannot otherwise reach. Promoted pairs inherit
q_a_pairsRLS exactly as curated pairs do;q_a_searchcontinues to apply its caller-side scope-tag overlap filter, so a promoted corpus pair is visible to a consumer only within that consumer’s scope, identically to any other published pair. -
AI-invisible infrastructure. Where the extraction (LLM extractor) or the embedding involves AI, that involvement is NOT surfaced as a user-facing “AI feature”: a promoted corpus pair behaves in
q_a_searchand the Q&A library exactly as if the Q&A had always been curated. A consumer cannot tell from the search/get contract that the pair was machine-extracted other than via the neutralorigin_kind='extracted_from_corpus'provenance value.
Open questions (collected)
Section titled “Open questions (collected)”- OQ-59-19-1 (INV-3): Auto post-re-ingest batch (v1 default, drives to
published) vs curated review queue (draft → in_review → published). Recommend auto for v1 (INV-23 needs post-run visibility without a human gate); curated as v1.1 opt-in on the same primitive. Liam ratify. - OQ-59-19-2 (INV-9): When an extraction is invalidated after its pair published — retire the
stale pair (
archived/superseded_by) or leave it published until a reconciliation step? Recommend leave-published + record lineage for v1; active retirement v1.1. Liam ratify. - OQ-59-19-3 (INV-11): Embedding-failure policy — surface-and-continue (recommended) vs abort-batch vs deferred re-embed pass. Liam ratify.
End of PRODUCT. Behaviour invariants INV-1..INV-16. Acceptance a Checker can verify against:
(INV-4) every route-i pair has origin_kind='extracted_from_corpus'; (INV-5) each promoted
extraction has promoted_to_pair_id set to its pair, 1:1; (INV-8) re-running promotion over the same
corpus state creates zero new pairs and the extracted_from_corpus count equals the
live-ever-promoted-extraction count (a fresh ID-45 re-walk emits new extraction rows and may
legitimately add pairs — the guarantee is scoped to identical corpus state, per INV-8 body);
(INV-10/INV-11) promoted pairs are publication_status='published' AND question_embedding IS NOT NULL, and appear in q_a_search results post-run with count > 0 consistent with promoted
extractions (ID-45 INV-23); (INV-12) lifecycle draft → in_review → published honoured (auto path
= published-direct). Companion {59.20} TECH (write-tech-spec) is the next chain step.