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.
Context preamble — the lossy write
Section titled “Context preamble — the lossy write”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:
- 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. - The cocoindex table schema
Q_A_EXTRACTIONS_SCHEMA(scripts/cocoindex_pipeline/flow.py:831-842) declares only 7 columns; none of the four appear. - The write site
qa_target.declare_row(...)(scripts/cocoindex_pipeline/flow.py:1230-1244) populates onlyid,source_content_item_id,extractor_kind,extracted_question_text,extracted_answer_text,extraction_metadata(a jsonb holding onlyextraction_kind,qa_index,rel_path), andop_id. None of the four extractor fields are written — not even stashed into theextraction_metadatajsonb.
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):
| Field | Declaration (verbatim) | Type | Nullability | Default |
|---|---|---|---|---|
question_text | question_text: str = Field(min_length=1) (:252) | str, non-empty | NOT NULL, min length 1 | none (required) |
answer_text | answer_text: str | None = None (:253) | str | None | nullable | None |
expected_response_kind | expected_response_kind: Literal["mandatory", "optional"] (:254) | Literal["mandatory","optional"] | NOT NULL | none — REQUIRED, no default |
evaluation_criteria | evaluation_criteria: str | None = None (:255) | str | None | nullable | None |
evidence_requirements | evidence_requirements: list[str] = Field(default_factory=list) (:256) | list[str] | NOT NULL (always a list) | [] (empty list) |
scope_tags | scope_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 isField(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 viaField(default_factory=list).model_config strict=True, extra="forbid"— confirmed (extraction.py:250).
Docstring collision note (confirmed): extraction.py:245-247 —
“expected_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.mdcarries no directory prefix in the docstring. The live file resides atdocs/themes/canonical-pipeline/intended-architecture/05-qa-flow.md(a path the docstring abbreviates). The earlierdocs/plans/phase-0-investigation/architecture/05-qa-flow.mdpath cited elsewhere in the codebase (and in the dispatch brief) is absent from this worktree — only thedocs/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_kindholds the form-type discriminator …'bid'/'rfp'/'pqq'etc.” — soexpected_response_kind(mandatory/optional) is a deliberately distinct concept fromquestion_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:111— “expected_response_kind: MUST be EXACTLY ONE of “mandatory” or “optional”. NEVER use “info_only” or any other value.” (corroborates theinfo_only-unratified docstring note).prompts.py:112— “evidence_requirements: list of zero or more required-evidence identifiers (e.g.["iso27001_certificate", "case_study"]). Empty list is acceptable.”prompts.py:113— “scope_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_kindmust always be present and one of two literals;evaluation_criteriais genuinely nullable;evidence_requirementsandscope_tagsare 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.
Per-field results
Section titled “Per-field results”| Field | Files referencing it | Verdict |
|---|---|---|
expected_response_kind | scripts/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.py | Extractor + prompt + their own tests only. No downstream consumer. |
evaluation_criteria | extraction.py, prompts.py, test_cocoindex_extraction.py, test_cocoindex_extractors.py | Extractor + prompt + tests only. No downstream consumer. |
evidence_requirements | extraction.py, prompts.py, test_cocoindex_extraction.py, test_cocoindex_extractors.py | Extractor + 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.sql | The 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_idappears 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)”), andsupabase/types/database.types.ts(the generated row shape).- No
.pyor.tscaller writes or readspromoted_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"overscripts/ lib/ app/ components/ hooks/ types/returns onlyscripts/cocoindex_pipeline/extraction.pyandscripts/cocoindex_pipeline/flow.py— i.e. the write side ofq_a_extractions, never a promotion reader. -
q_a_extractionshas 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_idcaptures 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).
Search RPCs, MCP tools, UI surfaces
Section titled “Search RPCs, MCP tools, UI surfaces”-
The Q&A search RPCs
q_a_search+q_a_get_verbatimare defined insupabase/migrations/20260520231524_t6_q_a_search_rpcs.sql(q_a_searchat:71-134,q_a_get_verbatimat:162-210). -
Neither RPC reads
q_a_extractions. Both querypublic.q_a_pairsonly (…rpcs.sql:116FROM public.q_a_pairs qap;…rpcs.sql:206FROM 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 onlysupabase/types/database.types.ts(generated) and__tests__/integration/q-a-pairs/two-step-retrieval.integration.test.ts(a test). The test inserts its ownq_a_pairsfixtures and exercises the RPC’sscope_tagpass-through (…two-step-retrieval.integration.test.ts:134, 276, 337-338); it does not touchq_a_extractionsnor any promotion path. There is no MCP tool, hook, route, or UI surface that callsq_a_search. -
q_a_searchMCP tool not found:rg -ln "q_a_search|q_a_get_verbatim" lib/ app/ scripts/is empty. (05-qa-flow.mdline 15 describes the MCPq_a_searchtool 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, notq_a_extractions).
§2 bottom line
Section titled “§2 bottom line”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:
-
The RPC pass-through (caller-side filter).
supabase/migrations/20260520231524_t6_q_a_search_rpcs.sql:- The design note
…rpcs.sql:31-34states scope filtering is CALLER-SIDE: the RPC returnsscope_tagas a pass-through column and the caller filters withWHERE scope_tag && caller_scope_tags(line 33). This is an array-overlap (&&) contract, explicitly not encoded inside the DB function. q_a_searchreturnsscope_tag text[](…rpcs.sql:82) sourced fromqap.scope_tag(…rpcs.sql:114);q_a_get_verbatimreturns bothscope_tag text[]andanti_scope_tag text[](…rpcs.sql:171-172,:195-196).
- The design note
-
The
q_a_pairscolumns (SINGULAR names).- Base shape:
supabase/migrations/20260520120828_t2_combined_pr_intel_shape_b_form_type_split.sql:358-359—scope_tag text[] NOT NULL DEFAULT ARRAY[]::text[]andanti_scope_tag text[] NOT NULL DEFAULT ARRAY[]::text[]. Both are SINGULAR (scope_tag,anti_scope_tag) — contrast the extractor’s PLURALscope_tags. - GIN indexes:
supabase/migrations/20260520225456_t6_q_a_pairs_full_schema.sql:285-289—idx_q_a_pairs_scope_tag … USING gin (scope_tag)andidx_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).
- Base shape:
-
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) joinsq_a_pairs.scope_tagagainstworkspaces.scope_tag. - CRITICAL FINDING —
workspaces.scope_tagdoes NOT exist as a column.rg "scope_tag" supabase/migrations/returns hits in only three files, all on theq_a_pairsfamily: the T2 base shape (…b_form_type_split.sql:358-359), the T6 full schema (q_a_pairshistory snapshot columns + GIN indexes), and the search-RPC migration (return columns + the one comment). No migration adds ascope_tagcolumn topublic.workspaces.- Cross-checked against the generated types: in
supabase/types/database.types.tsall 15scope_tagoccurrences fall on theq_a_pairs/q_a_pair_historyblocks (lines 2523-2628) and the RPC return rows (lines 4301-4324). Theworkspacestable block spans lines 3451-3508 (next tablequality_issues_pendingbegins at 3509) — noscope_tagappears inside it. - Conclusion: the
scope_tag && workspaces.scope_tagfilter described in the migration comment and05-qa-flow.mdis an intended/future contract, not a live one. The canonical scope vocabulary has no materialised source in this worktree: there is noworkspaces.scope_tagcolumn, no taxonomy table namedscope_tag/scope_tags, and no code that populates eitherq_a_pairs.scope_tagor any workspace scope field (rg -l "caller_scope_tags"over non-SQL/non-docs returns nothing; the onlyq_a_pairs.scope_tagwriters are test fixtures in the integration test). - Unverified: whether a
workspaces.scope_tagcolumn is planned under a separate Task or whether scope vocabulary is intended to derive from an existing taxonomy (e.g.domains/subtopicsin the taxonomy snapshot, orform_types). No artefact in this worktree pins the canonical scope-vocabulary source. {54.3} TECH must resolve this with Liam.
- The intended filter (per the migration comment above and
The load-bearing question
Section titled “The load-bearing question”Must
q_a_extractions.scope_tags(plural, from the extractor) reuse the EXISTINGscope_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_requirementstable (renamed fromtemplate_requirementsat…b_form_type_split.sql:242; original CREATE atsupabase/migrations/20260416102457_pre_squash_reconciliation.sql:4142). It carries arequirement_typeCHECK 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 likeiso27001_certificate). So it is coarse-grained and a poor direct vocabulary forevidence_requirements. There is no fine-grained evidence- artefact controlled vocabulary anywhere in the migrations orscripts/corpus (rgfor evidence/requirement vocab surfaces only theform_template_requirementsfamily 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 sharescope_tags’ inert-column risk because nothing filters on it. TECH may noteform_template_requirements.requirement_typeas a future normalisation anchor but should not block ID-54 on inventing an evidence taxonomy.
§4 — Column-type options table
Section titled “§4 — Column-type options table”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)”| Option | DDL sketch | Trade-offs |
|---|---|---|
| Postgres native enum | CREATE TYPE … AS ENUM ('mandatory','optional'); column … NOT NULL | Strongest 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 precedent — extractor_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 text | expected_response_kind text NOT NULL | No 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 (
strictLiteral), so the DB constraint is defence-in-depth. Theinfo_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)”| Option | DDL sketch | Trade-offs |
|---|---|---|
text NULL | evaluation_criteria text NULL | Matches 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)”| Option | DDL sketch | Trade-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_metadata | stash 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)”| Option | DDL sketch | Trade-offs |
|---|---|---|
text[] PLURAL name | scope_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 name | scope_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_metadata | stash 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)”- OQ-54-A (CRITICAL): Where is the canonical scope vocabulary sourced from, and is
workspaces.scope_tagplanned? It does not exist as a column in this worktree, yet the entirescope_tagretrieval contract depends on it. Blocks the §3 alignment decision. - OQ-54-B: Should
q_a_extractionsstore the scope field under the plural name (scope_tags, matching the extractor) or the singular name (scope_tag, pre-aligning the promotion mapping toq_a_pairs.scope_tag)? - OQ-54-C: Should the LLM
scope_tags(andevidence_requirements) prompt be constrained to a controlled vocabulary (mirroring theform_typesnapshot-validator pattern), or persisted free-text now and normalised at promotion? Constraining presupposes OQ-54-A. - 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)? - OQ-54-E (adjacent, out of scope but surfaced):
flow.py:1234writesextractor_kind = content_type or "q_a_form"into theq_a_extractions.extractor_kindcolumn, 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 acontent_typevalue 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 verydeclare_rowthat carries it.
Anything unverifiable
Section titled “Anything unverifiable”- The dispatch-brief doc path
docs/plans/phase-0-investigation/architecture/05-qa-flow.mdis absent from this worktree. The live copy isdocs/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_searchtool is registered/built anywhere: not found inlib/,app/,scripts/.05-qa-flow.mdline 15 describes it as the intended retrieval surface; its existence as built code is unverified (none found). - Whether a
workspaces.scope_tagcolumn 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_onlyQ&A-kind value is noted as unratified in bothextraction.py:247andprompts.py:111; its eventual ratification status is outside this RESEARCH and unverified.