Skip to content

ID-54 {54.1} RESEARCH — qaextractions lossy-write fix (S273 OQ-52-LOSSY)

ID-54 {54.1} RESEARCH — q_a_extractions lossy-write fix (S273 OQ-52-LOSSY)

Section titled “ID-54 {54.1} RESEARCH — q_a_extractions lossy-write fix (S273 OQ-52-LOSSY)”

Artefact: RESEARCH (investigation only — this document does NOT design the fix; the column-type decision, the scope-vocabulary resolution, and the migration plan are owned by {54.3} TECH + Liam’s ratification).

Author discipline: Every claim cites file:line. Where a referenced artefact is absent from this worktree, the claim is flagged unverified explicitly. UK English throughout.


Code-intelligence rationale (Checker audit note)

Section titled “Code-intelligence rationale (Checker audit note)”

This research touches .py and .sql files only. Per .ast-dataflow/CLAUDE.md (“ast-dataflow does not cover Python or SQL files”) and the GitNexus index scope (the “GitNexus — Code Intelligence” block in CLAUDE.md indexes the TypeScript corpus), both gitnexus_query / gitnexus_context and the ast-dataflow CLI are structurally blind to the files under investigation here (scripts/cocoindex_pipeline/*.py, supabase/migrations/*.sql). Code-intelligence orientation is therefore correctly judged N/A for this Subtask; the orientation substrate is grep/rg over the Python + SQL corpus (the directive’s own prescribed fallback for those file types). The one TypeScript touch-point — the generated supabase/types/database.types.ts — is a generated artefact (never hand-edited; CI-guarded by supabase-types-parity), inspected read-only here only to confirm the absence of a column, not as a modification target.


The cocoindex Path-A Q&A-form extractor (extract_qa_form, returning a QAFormExtraction carrying qa_pairs: list[QAPair]) emits four form-question fields per Q&A pair that are dropped at write time. The loss is structural and exists at three layers, all confirmed present in this worktree:

  1. The DB table public.q_a_extractions (supabase/migrations/20260520225456_t6_q_a_pairs_full_schema.sql:103-127) carries no column for any of the four fields.
  2. The cocoindex table schema Q_A_EXTRACTIONS_SCHEMA (scripts/cocoindex_pipeline/flow.py:831-842) declares only 7 columns; none of the four appear.
  3. The write site qa_target.declare_row(...) (scripts/cocoindex_pipeline/flow.py:1230-1244) populates only id, source_content_item_id, extractor_kind, extracted_question_text, extracted_answer_text, extraction_metadata (a jsonb holding only extraction_kind, qa_index, rel_path), and op_id. None of the four extractor fields are written — not even stashed into the extraction_metadata jsonb.

cocoindex’s TableTarget.declare_row(row=...) writes only the keys present in the row= dict (the row-construction site at flow.py:1231-1243), so closing the loss requires all three layers to change. The existing in-code marker confirms the scope ownership: flow.py:1289-1291“the existing Path-A q_a_extractions write above is NOT touched … The lossy-Path-A fix is ID-54.”


§1 — Exact extractor field names + guarantees

Section titled “§1 — Exact extractor field names + guarantees”

All four fields live on the QAPair Pydantic model at scripts/cocoindex_pipeline/extraction.py:242-257. The model carries model_config = ConfigDict(strict=True, extra="forbid") (extraction.py:250) — confirming strict=True, extra="forbid" as stated in the brief.

The full QAPair field set (extraction.py:252-257):

FieldDeclaration (verbatim)TypeNullabilityDefault
question_textquestion_text: str = Field(min_length=1) (:252)str, non-emptyNOT NULL, min length 1none (required)
answer_textanswer_text: str | None = None (:253)str | NonenullableNone
expected_response_kindexpected_response_kind: Literal["mandatory", "optional"] (:254)Literal["mandatory","optional"]NOT NULLnone — REQUIRED, no default
evaluation_criteriaevaluation_criteria: str | None = None (:255)str | NonenullableNone
evidence_requirementsevidence_requirements: list[str] = Field(default_factory=list) (:256)list[str]NOT NULL (always a list)[] (empty list)
scope_tagsscope_tags: list[str] = Field(default_factory=list) (:257)list[str]NOT NULL (always a list)[] (empty list)

Forensic findings confirmed:

  • expected_response_kind: Literal["mandatory","optional"]REQUIRED, no default (extraction.py:254). Confirmed.
  • evaluation_criteria: str | None = None — confirmed (extraction.py:255).
  • evidence_requirements: list[str] with empty-list default — confirmed (extraction.py:256). The brief wrote = []; the actual idiom is Field(default_factory=list), which is the semantically-equivalent (and Pydantic-correct, no shared-mutable) form — same empty-list guarantee.
  • scope_tags: list[str] (PLURAL) with empty-list default — confirmed (extraction.py:257), again via Field(default_factory=list).
  • model_config strict=True, extra="forbid" — confirmed (extraction.py:250).

Docstring collision note (confirmed): extraction.py:245-247expected_response_kind is named to avoid collision with question_matches.question_kind per 05-qa-flow.md §7.2; the 2-value CV is canonical (info_only unratified).”

  • The cited doc path 05-qa-flow.md carries no directory prefix in the docstring. The live file resides at docs/themes/canonical-pipeline/intended-architecture/05-qa-flow.md (a path the docstring abbreviates). The earlier docs/plans/phase-0-investigation/architecture/05-qa-flow.md path cited elsewhere in the codebase (and in the dispatch brief) is absent from this worktree — only the docs/themes/canonical-pipeline/intended-architecture/ copy and an archived verifier report (.planning/.archive/.specs/.../05-qa-flow-verifier.md) exist.
  • 05-qa-flow.md §7.2 (the live copy, lines 218-225) confirms the collision rationale: question_matches.question_kind holds the form-type discriminator … 'bid' / 'rfp' / 'pqq' etc.” — so expected_response_kind (mandatory/optional) is a deliberately distinct concept from question_kind (the form-type discriminator).

Prompt-shape corroboration (the JSON contract the LLM is instructed to emit) at scripts/cocoindex_pipeline/prompts.py:91-102 (the qa_pairs[*] object) and the FIELD CONSTRAINTS at prompts.py:104-113:

  • prompts.py:95"expected_response_kind": <one of: mandatory, optional>.
  • prompts.py:96"evaluation_criteria": <description of how the response is evaluated, OR null>.
  • prompts.py:97"evidence_requirements": [<list of required evidence types>].
  • prompts.py:98"scope_tags": [<list of scope identifiers>] (PLURAL — matches the model).
  • prompts.py:111expected_response_kind: MUST be EXACTLY ONE of “mandatory” or “optional”. NEVER use “info_only” or any other value.” (corroborates the info_only-unratified docstring note).
  • prompts.py:112evidence_requirements: list of zero or more required-evidence identifiers (e.g. ["iso27001_certificate", "case_study"]). Empty list is acceptable.”
  • prompts.py:113scope_tags: list of zero or more scope identifiers. Empty list is acceptable.”

Critical nullability constraint for §4: because the model is strict=True, the DB column types chosen in {54.3} TECH must round-trip the extractor guarantees exactly: expected_response_kind must always be present and one of two literals; evaluation_criteria is genuinely nullable; evidence_requirements and scope_tags are never null at the model layer (always a list, possibly empty).


§2 — Consumer inventory: who reads these four fields downstream?

Section titled “§2 — Consumer inventory: who reads these four fields downstream?”

Method. rg -l "<field>" scripts/ lib/ app/ supabase/ components/ hooks/ types/ for each field, then targeted follow-up greps for the promotion path, the search RPC, and MCP tools across the whole repo.

FieldFiles referencing itVerdict
expected_response_kindscripts/cocoindex_pipeline/extraction.py, scripts/cocoindex_pipeline/prompts.py, scripts/tests/test_cocoindex_extraction.py, scripts/tests/test_cocoindex_extractors.py, scripts/tests/test_cocoindex_prompts.pyExtractor + prompt + their own tests only. No downstream consumer.
evaluation_criteriaextraction.py, prompts.py, test_cocoindex_extraction.py, test_cocoindex_extractors.pyExtractor + prompt + tests only. No downstream consumer.
evidence_requirementsextraction.py, prompts.py, test_cocoindex_extraction.py, test_cocoindex_extractors.pyExtractor + prompt + tests only. No downstream consumer.
scope_tags (plural)extraction.py, prompts.py, test_cocoindex_extraction.py, test_cocoindex_extractors.py, and supabase/migrations/20260520231524_t6_q_a_search_rpcs.sqlThe migration hit is a false positive: it is the string caller_scope_tags inside a comment at …rpcs.sql:33 (WHERE scope_tag && caller_scope_tags), not a reference to the extractor’s scope_tags. So: extractor + prompt + tests only. No downstream consumer.

Forensic finding confirmed: all four fields appear ONLY in extraction.py, prompts.py, and their own tests. There is no production reader of any of the four fields anywhere in scripts/ lib/ app/ supabase/ components/ hooks/ types/.

The q_a_extractions → q_a_pairs promotion path (UC5)

Section titled “The q_a_extractions → q_a_pairs promotion path (UC5)”
  • Schema lineage column exists, code does not. promoted_to_pair_id appears only in:

    • supabase/migrations/20260520225456_t6_q_a_pairs_full_schema.sql:120-121 (promoted_to_pair_id uuid NULL REFERENCES public.q_a_pairs(id) ON DELETE SET NULL, commented “Lineage to corpus pair — set when extraction is promoted (UC5 flow per §9)”), and
    • supabase/types/database.types.ts (the generated row shape).
    • No .py or .ts caller writes or reads promoted_to_pair_id. (rg -l "promoted_to_pair_id" scripts/ lib/ app/ … returns only the migration + the generated types.)
  • No promotion code exists. rg -ln "promote.*pair|promoteExtraction|q_a_extractions" over scripts/ lib/ app/ components/ hooks/ types/ returns only scripts/cocoindex_pipeline/extraction.py and scripts/cocoindex_pipeline/flow.py — i.e. the write side of q_a_extractions, never a promotion reader.

  • q_a_extractions has no TypeScript reader at all: rg -l "q_a_extractions" lib/ app/ components/ hooks/ types/ is empty; rg -l "q_a_extractions" lib/mcp/ is empty.

  • Doc corroboration: 05-qa-flow.md §9 (lines 271-285, the live copy) describes UC5 (“bid response → Q&A pair”) as a ratified KH-DB-only operation and §3.1 (line 85) states “once promoted, promoted_to_pair_id captures the lineage” — but this is an intended-architecture description; the operation is not yet implemented.

    Verdict: the promotion path is DORMANT (schema substrate present; zero code).

  • The Q&A search RPCs q_a_search + q_a_get_verbatim are defined in supabase/migrations/20260520231524_t6_q_a_search_rpcs.sql (q_a_search at :71-134, q_a_get_verbatim at :162-210).

  • Neither RPC reads q_a_extractions. Both query public.q_a_pairs only (…rpcs.sql:116 FROM public.q_a_pairs qap; …rpcs.sql:206 FROM public.q_a_pairs qap). The extractor’s output table is therefore not on the search read-path even indirectly — the search-path reads the promoted corpus (q_a_pairs), and nothing promotes into it yet.

  • No production caller of q_a_search. rg -ln "q_a_search\b" . -g '!*.sql' -g '!docs/**' returns only supabase/types/database.types.ts (generated) and __tests__/integration/q-a-pairs/two-step-retrieval.integration.test.ts (a test). The test inserts its own q_a_pairs fixtures and exercises the RPC’s scope_tag pass-through (…two-step-retrieval.integration.test.ts:134, 276, 337-338); it does not touch q_a_extractions nor any promotion path. There is no MCP tool, hook, route, or UI surface that calls q_a_search.

  • q_a_search MCP tool not found: rg -ln "q_a_search|q_a_get_verbatim" lib/ app/ scripts/ is empty. (05-qa-flow.md line 15 describes the MCP q_a_search tool as the intended retrieval surface — unverified as built; no tool registration exists in this worktree.)

    Verdict: the scope-search consumer chain is DORMANT (RPCs defined; no production caller; reads q_a_pairs, not q_a_extractions).

All four fields are captured-but-unconsumed, not actively-blocking. Closing the lossy write persists data that has no live reader today; its value is realised only once the (currently dormant) UC5 promotion path and the (currently caller-less) q_a_search chain are built. This makes ID-54 a forward-substrate fix: it stops silent loss of LLM output so the data is available when the consumers land, rather than unblocking a presently-broken read. {54.2} PRODUCT / {54.3} TECH should treat “no current reader” as a sequencing fact, not a reason to defer — re-extraction to recover dropped fields later is the cost being avoided.


§3 — ★ CRITICAL: scope_tags vocabulary alignment

Section titled “§3 — ★ CRITICAL: scope_tags vocabulary alignment”

The existing scope-matching mechanism (the contract scope_tags would have to join)

Section titled “The existing scope-matching mechanism (the contract scope_tags would have to join)”

There is a pre-existing, caller-side scope-matching mechanism on the promoted corpus (q_a_pairs), distinct from the extractor’s scope_tags:

  1. The RPC pass-through (caller-side filter). supabase/migrations/20260520231524_t6_q_a_search_rpcs.sql:

    • The design note …rpcs.sql:31-34 states scope filtering is CALLER-SIDE: the RPC returns scope_tag as a pass-through column and the caller filters with WHERE scope_tag && caller_scope_tags (line 33). This is an array-overlap (&&) contract, explicitly not encoded inside the DB function.
    • q_a_search returns scope_tag text[] (…rpcs.sql:82) sourced from qap.scope_tag (…rpcs.sql:114); q_a_get_verbatim returns both scope_tag text[] and anti_scope_tag text[] (…rpcs.sql:171-172, :195-196).
  2. The q_a_pairs columns (SINGULAR names).

    • Base shape: supabase/migrations/20260520120828_t2_combined_pr_intel_shape_b_form_type_split.sql:358-359scope_tag text[] NOT NULL DEFAULT ARRAY[]::text[] and anti_scope_tag text[] NOT NULL DEFAULT ARRAY[]::text[]. Both are SINGULAR (scope_tag, anti_scope_tag) — contrast the extractor’s PLURAL scope_tags.
    • GIN indexes: supabase/migrations/20260520225456_t6_q_a_pairs_full_schema.sql:285-289idx_q_a_pairs_scope_tag … USING gin (scope_tag) and idx_q_a_pairs_anti_scope_tag … USING gin (anti_scope_tag) (built to support the && overlap operator).
    • The workspace-overlap contract is documented in the same migration’s header comment, …full_schema.sql:279-280: WHERE q_a_pairs.scope_tag && workspaces.scope_tag AND NOT (q_a_pairs.anti_scope_tag && workspaces.scope_tag).
  3. The canonical scope vocabulary — where does it come from?

    • The intended filter (per the migration comment above and 05-qa-flow.md §1 lines 22-23 and §7.2 line 225) joins q_a_pairs.scope_tag against workspaces.scope_tag.
    • CRITICAL FINDING — workspaces.scope_tag does NOT exist as a column.
      • rg "scope_tag" supabase/migrations/ returns hits in only three files, all on the q_a_pairs family: the T2 base shape (…b_form_type_split.sql:358-359), the T6 full schema (q_a_pairs history snapshot columns + GIN indexes), and the search-RPC migration (return columns + the one comment). No migration adds a scope_tag column to public.workspaces.
      • Cross-checked against the generated types: in supabase/types/database.types.ts all 15 scope_tag occurrences fall on the q_a_pairs / q_a_pair_history blocks (lines 2523-2628) and the RPC return rows (lines 4301-4324). The workspaces table block spans lines 3451-3508 (next table quality_issues_pending begins at 3509) — no scope_tag appears inside it.
      • Conclusion: the scope_tag && workspaces.scope_tag filter described in the migration comment and 05-qa-flow.md is an intended/future contract, not a live one. The canonical scope vocabulary has no materialised source in this worktree: there is no workspaces.scope_tag column, no taxonomy table named scope_tag/scope_tags, and no code that populates either q_a_pairs.scope_tag or any workspace scope field (rg -l "caller_scope_tags" over non-SQL/non-docs returns nothing; the only q_a_pairs.scope_tag writers are test fixtures in the integration test).
      • Unverified: whether a workspaces.scope_tag column is planned under a separate Task or whether scope vocabulary is intended to derive from an existing taxonomy (e.g. domains/subtopics in the taxonomy snapshot, or form_types). No artefact in this worktree pins the canonical scope-vocabulary source. {54.3} TECH must resolve this with Liam.

Must q_a_extractions.scope_tags (plural, from the extractor) reuse the EXISTING scope_tag (singular) vocabulary + array-overlap contract, rather than inventing a parallel one?

RESEARCH bottom line (options + trade-offs, decision deferred to TECH + Liam):

The evidence points strongly toward alignment-required rather than parallel-OK — but with a material caveat that the “existing vocabulary” is itself not yet materialised (workspaces.scope_tag is absent). The case for alignment: the entire downstream value of scope_tags is the workspace-relevance &&-overlap filter on the promoted corpus (q_a_pairs.scope_tag && workspaces.scope_tag). If the extractor’s scope_tags do not feed that same vocabulary (via the UC5 promotion mapping q_a_extractions.scope_tags → q_a_pairs.scope_tag), they can never participate in the only mechanism that consumes scope tags. A parallel vocabulary would be semantically inert the moment it tried to overlap. The counter-pressure: because the canonical vocabulary source does not yet exist, {54.3} cannot bind scope_tags to a concrete controlled list today — it can only guarantee the shape (text[]) and the promotion-mapping intent. This is a sequencing tension TECH must surface to Liam, not paper over.

The three sub-problems (all confirmed; resolution deferred):

(a) Plural-vs-singular naming + the promotion-time mapping. The extractor emits scope_tags (plural, extraction.py:257); the corpus column is scope_tag (singular, …b_form_type_split.sql:358). There is no naming convergence today. The promotion path (UC5, dormant per §2) would have to map q_a_extractions.scope_tags → q_a_pairs.scope_tag. TECH options: (i) store on q_a_extractions under the plural name matching the extractor (scope_tags text[]) and rename at promotion; (ii) store under the singular name to pre-align with the corpus (scope_tag text[]) at the cost of a name mismatch against the Pydantic field; (iii) store in the extraction_metadata jsonb to avoid committing to a column name before the vocabulary is settled. Each trades off forensic clarity vs future-mapping cost.

(b) The extractor emits NO anti_scope_tag though the workspace filter needs it. The workspace-overlap contract has TWO arms: scope_tag && workspaces.scope_tag AND NOT (anti_scope_tag && workspaces.scope_tag) (…full_schema.sql:279-280). The corpus carries both scope_tag and anti_scope_tag (…b_form_type_split.sql:358-359), but the extractor’s QAPair has only scope_tags and no anti-scope field (extraction.py:252-257; confirmed rg "anti_scope" scripts/cocoindex_pipeline/ is empty). So an extraction can never populate the exclusion arm of the filter at promotion time — anti_scope_tag would default to empty {}. TECH must decide whether that is acceptable for v1 (extractions are inclusion-only; exclusion is a curation-time concern added at promotion) or whether the extractor contract should grow an anti-scope field (a {54.x} extractor + prompt change, wider than a write fix).

(c) Controlled-vocabulary alignment. The LLM is instructed only to emit “zero or more scope identifiers” (prompts.py:113) with no enumerated value list — contrast content_type / form_type / entity_type, which the prompt enumerates verbatim and the model hard-validates against a snapshot (extraction.py:228-239, 322-333). So scope_tags is currently free-text. If promoted as-is, free-text scope tags will almost never &&-overlap a controlled workspaces.scope_tag set, rendering the column semantically inert for retrieval (exactly the failure mode the brief flags). TECH options: (i) constrain the prompt + add a snapshot-backed validator mirroring the form_type pattern (extraction.py:228-239) — but this presupposes a canonical scope vocabulary that does not yet exist (see CRITICAL FINDING above); (ii) persist free-text now and normalise at promotion time; (iii) defer scope-tag persistence until the vocabulary source is ratified. This is the sharpest trade-off and is tightly coupled to sub-problem (a) and to the missing workspaces.scope_tag.

Lighter parallel analysis — evidence_requirements

Section titled “Lighter parallel analysis — evidence_requirements”
  • The extractor emits evidence_requirements: list[str] free-text (extraction.py:256), prompted as “required-evidence identifiers (e.g. ["iso27001_certificate", "case_study"])” (prompts.py:112) — again no enumerated controlled list.
  • Is there an existing evidence/requirement vocabulary to align to? There IS a candidate: the form_template_requirements table (renamed from template_requirements at …b_form_type_split.sql:242; original CREATE at supabase/migrations/20260416102457_pre_squash_reconciliation.sql:4142). It carries a requirement_type CHECK enum of seven values — 'policy' | 'statement' | 'evidence' | 'data' | 'narrative' | 'declaration' | 'reference' (…pre_squash_reconciliation.sql:4167). However this is a requirement-type classification, NOT an evidence-artefact taxonomy (it has a single 'evidence' bucket, not a list of evidence kinds like iso27001_certificate). So it is coarse-grained and a poor direct vocabulary for evidence_requirements. There is no fine-grained evidence- artefact controlled vocabulary anywhere in the migrations or scripts/ corpus (rg for evidence/requirement vocab surfaces only the form_template_requirements family and template-coverage code, none of which enumerates evidence kinds).
  • RESEARCH bottom line for evidence_requirements: absent a fine-grained evidence taxonomy, this field is best treated as free-form for v1 (no &&-overlap retrieval semantics depend on it today — confirmed no consumer in §2). It does not share scope_tags’ inert-column risk because nothing filters on it. TECH may note form_template_requirements.requirement_type as a future normalisation anchor but should not block ID-54 on inventing an evidence taxonomy.

Decision deferred to {54.3} TECH; this table lays out options + trade-offs only. Nullability of each option must match the §1 extractor guarantees (noted per row).

expected_response_kind (§1: REQUIRED, Literal["mandatory","optional"], no default)

Section titled “expected_response_kind (§1: REQUIRED, Literal["mandatory","optional"], no default)”
OptionDDL sketchTrade-offs
Postgres native enumCREATE TYPE … AS ENUM ('mandatory','optional'); column … NOT NULLStrongest integrity; but enum value additions require a migration + ALTER TYPE; diverges from the existing in-table precedent (which uses text + CHECK).
text + CHECK (precedent match)expected_response_kind text NOT NULL CHECK (expected_response_kind IN ('mandatory','optional'))Mirrors the existing q_a_extractions.extractor_kind precedentextractor_kind text NOT NULL CHECK (extractor_kind IN (...)) at …full_schema.sql:109-115. Easy to extend; CHECK error is clear. Recommended-shape per precedent (TECH to ratify). NOT NULL satisfies the REQUIRED guarantee.
Plain textexpected_response_kind text NOT NULLNo value constraint; relies on the Pydantic Literal upstream only. Weakest; loses DB-level enforcement of the 2-value CV.

Note: the Pydantic side already hard-rejects out-of-CV values (strict Literal), so the DB constraint is defence-in-depth. The info_only-unratified note (extraction.py:247, prompts.py:111) means TECH should NOT pre-add a third CHECK value.

evaluation_criteria (§1: nullable str | None, default None)

Section titled “evaluation_criteria (§1: nullable str | None, default None)”
OptionDDL sketchTrade-offs
text NULLevaluation_criteria text NULLMatches the extractor guarantee exactly (genuinely nullable free-text). No constraint needed. Straightforward; precedent is q_a_extractions.extracted_answer_text text NULL (…full_schema.sql:117).

evidence_requirements (§1: NOT NULL list, default []; §3: free-form for v1)

Section titled “evidence_requirements (§1: NOT NULL list, default []; §3: free-form for v1)”
OptionDDL sketchTrade-offs
text[] NOT NULL DEFAULT '{}' (array precedent)evidence_requirements text[] NOT NULL DEFAULT '{}'::text[]Mirrors the q_a_pairs array precedent — e.g. scope_tag text[] NOT NULL DEFAULT ARRAY[]::text[] (…b_form_type_split.sql:358) and alternate_question_phrasings text[] NOT NULL DEFAULT '{}' (…full_schema.sql:53). Matches the extractor’s never-null-empty-list guarantee. Free-form per §3 (no CHECK). Recommended-shape per precedent.
jsonb in extraction_metadatastash under extraction_metadata->'evidence_requirements'Avoids a column; but loses queryability + the array-typing the extractor guarantees, and buries the field. Not recommended given the dedicated-column precedent.

scope_tags (§1: NOT NULL list, default [], PLURAL; cross-ref §3 — naming + vocab)

Section titled “scope_tags (§1: NOT NULL list, default [], PLURAL; cross-ref §3 — naming + vocab)”
OptionDDL sketchTrade-offs
text[] PLURAL namescope_tags text[] NOT NULL DEFAULT '{}'::text[]Name-matches the extractor field (extraction.py:257); diverges from the corpus scope_tag (singular). Requires a rename at promotion (§3 sub-problem a).
text[] SINGULAR namescope_tag text[] NOT NULL DEFAULT '{}'::text[]Pre-aligns with the corpus q_a_pairs.scope_tag for a clean promotion mapping; diverges from the Pydantic field name (forensic-clarity cost).
jsonb in extraction_metadatastash under extraction_metadata->'scope_tags'Defers the naming + vocabulary commitment (§3) until workspaces.scope_tag + the canonical vocabulary land; costs queryability + a GIN index.
(coupled to §3)Whichever shape is chosen, the column is semantically inert for retrieval until (i) a canonical scope vocabulary exists, (ii) workspaces.scope_tag exists, and (iii) the LLM is constrained to that vocabulary (§3 sub-problem c). TECH must NOT decide this without resolving §3 with Liam. All options use text[] NOT NULL DEFAULT '{}' to honour the §1 never-null guarantee; the open question is the name and the vocabulary binding, not the base type.

Open questions surfaced (for {54.2}/{54.3} + Liam)

Section titled “Open questions surfaced (for {54.2}/{54.3} + Liam)”
  1. OQ-54-A (CRITICAL): Where is the canonical scope vocabulary sourced from, and is workspaces.scope_tag planned? It does not exist as a column in this worktree, yet the entire scope_tag retrieval contract depends on it. Blocks the §3 alignment decision.
  2. OQ-54-B: Should q_a_extractions store the scope field under the plural name (scope_tags, matching the extractor) or the singular name (scope_tag, pre-aligning the promotion mapping to q_a_pairs.scope_tag)?
  3. OQ-54-C: Should the LLM scope_tags (and evidence_requirements) prompt be constrained to a controlled vocabulary (mirroring the form_type snapshot-validator pattern), or persisted free-text now and normalised at promotion? Constraining presupposes OQ-54-A.
  4. OQ-54-D: The extractor emits no anti_scope_tag; is inclusion-only scoping acceptable for v1 extractions (exclusion added at curation/promotion), or should the extractor grow an anti-scope field (a wider change than a write fix)?
  5. OQ-54-E (adjacent, out of scope but surfaced): flow.py:1234 writes extractor_kind = content_type or "q_a_form" into the q_a_extractions.extractor_kind column, whose CHECK only permits 'prior_bid_response' | 'llm_extraction' | 'yaml_frontmatter_v1' | 'markdown_heading_v1' (…full_schema.sql:109-115). Neither "q_a_form" nor a content_type value is in that CHECK set — this is a pre-existing constraint-violation risk on the same write site ID-54 touches. Flagged for the Orchestrator (may warrant folding into ID-54 or a sibling Task). Not part of the four-field loss, but the Executor will be editing the very declare_row that carries it.

  • The dispatch-brief doc path docs/plans/phase-0-investigation/architecture/05-qa-flow.md is absent from this worktree. The live copy is docs/themes/canonical-pipeline/intended-architecture/05-qa-flow.md; an archived verifier report exists at .planning/.archive/.specs/wp4-architecture-split-verifier-reports/05-qa-flow-verifier.md. All §3/§7.2/§9 doc citations above are against the live copy.
  • Whether the MCP q_a_search tool is registered/built anywhere: not found in lib/, app/, scripts/. 05-qa-flow.md line 15 describes it as the intended retrieval surface; its existence as built code is unverified (none found).
  • Whether a workspaces.scope_tag column or a canonical scope-vocabulary table is planned under another Task: unverified — no artefact in this worktree pins it (drives OQ-54-A).
  • The info_only Q&A-kind value is noted as unratified in both extraction.py:247 and prompts.py:111; its eventual ratification status is outside this RESEARCH and unverified.