ID-63 {63.3} TECH — Ontology / controlled-vocabulary (CV) integration-correctness gate: implementation plan
ID-63 {63.3} TECH — Ontology / controlled-vocabulary (CV) integration-correctness gate: implementation plan
Section titled “ID-63 {63.3} TECH — Ontology / controlled-vocabulary (CV) integration-correctness gate: implementation plan”- Task: ID-63 — Ontology / controlled-vocabulary (CV) integration verification across the canonical pipeline.
- Subtask: {63.3} TECH (this document). Proposed changes mapped one-to-one to the
{63.2}PRODUCT invariantsInv-1 … Inv-12, plus a migration/build plan. - Status: TECH — prescribes implementation. Does NOT decompose into Subtask records (that is
{63.4}PLAN; this doc closes with a slice outline only). - Author: fresh task-planner instance (Q-PLANNER-2 — NOT the author of
{63.1}or{63.2}), opus thinking:max, isolated worktree offmain. - Date: 29/05/2026.
- Last reconciled: 29/05/2026 — surgical OQ-63-9 reconcile: §0.4, §3.7, §4.1, §5.1 amended to match the
{63.4}PLAN’s S279 correction and Liam’s subsequent OQ-63-9 ratification (NOT-NULL +'unclassified'sentinel +/review-tab/dashboard surfacing, pre-re-ingest). Previously this TECH read “Inv-7 v1 =primary_domainonly / defer subtopic / no DDL” — that is superseded; see §0.4. - Language: UK English throughout (colour, organise, behaviour, DD/MM/YYYY).
- Predecessors (read in full):
docs/specs/id-63-ontology-cv-integration/RESEARCH.md({63.1});docs/specs/id-63-ontology-cv-integration/PRODUCT.md({63.2}). This TECH inherits the ratified decisions from{63.2}and the S279 Liam ratifications recorded in §0; it does not re-open them.
Reading guide. §0 records the ratified decisions this TECH encodes (do-not-reopen). §1 is the code-intelligence orientation (cited verbatim, re-run for {63.3} per Q-PLANNER-2). §2 is the pre-ratification empirical verification block. §3 is the proposed-change set — one §3.N subsection per PRODUCT
Inv-N, each naming files, symbols, and acceptance. §4 is the migration / build plan (ordering, chain-dependencies, commands). §5 records the new TECH-surfaced findings (the cocoindex shape has noprimary_subtopic; theeditable_viafilter excludesrequirement_type). §6 is the {63.4} PLAN slice outline (NOT authored here). §7 is the residual OQ hand-off.
0. Ratified decisions this TECH encodes (S279 Liam — do NOT re-open)
Section titled “0. Ratified decisions this TECH encodes (S279 Liam — do NOT re-open)”- Inv-7 = SOFT-WARN + coverage, not silent-coercion, not hard-reject. On the cocoindex re-ingest path (
scripts/cocoindex_pipeline/extraction.py), an out-of-taxonomyprimary_domainvalue is written (the extraction is not dropped) but emits a surfaced out-of-taxonomy warning + a coverage metric (a flow-scope taxonomy-miss counter that rides the existing flow-end webhook). It is NOT silent-coercion (the TSvalidateDomaincoerce-to-validDomains[0]is an explicit smell per PRODUCT Inv-7 rationale — do NOT replicate it) and NOT hard-reject. The surface mirrors the Inv-17 graceful-with-surfaced-reason shape already used by the embedding stage counter + retry counter (§3.7). - OQ-63-1a = SHRINK to 8.
framework/dps/gcloudwere migrated out ofform_typeby the T2 combined-PR migration (20260520120828_t2_combined_pr_intel_shape_b_form_type_split.sql) intoprocurement_vehicle(CV30) /procurement_vehicle_instance(CV31). They are NOT forms. The fix shrinks the prompt + its locking test to the canonical 8; it does NOT grow the CV back to 11 (§3.1). - Scope (PRODUCT §Scope / OQ-63-5 + OQ-63-8): the in-scope deliverable surface is the CV integration-correctness contract on the cocoindex re-ingest path (
scripts/cocoindex_pipeline/extraction.py+prompts.py) and its triple-source lockstep guards. The app-sidelib/ai/classify.tspath is unmodified (referenced only as the Inv-6/Inv-7 parity baseline).scripts/kb_pipeline/classify.pyretirement andscripts/ontology-sync/parse-flow.pydisposition are follow-up dispositions, not deliverables (§7). - OQ-63-9 = RESOLVED-VIA-FIX, subtopic in scope, NOT-NULL + sentinel, surfaced via existing surfaces (S279 + Liam OQ-63-9 ratification — SUPERSEDES this TECH’s original §5.1 “domain-only / defer subtopic to T7/ID-45 / no DDL” stance). Pre-re-ingest is the time to FIX the underlying schema, not work around it. The ratified resolution: (a) ADD
primary_subtopictoClassificationExtraction+ theCLASSIFICATION_PROMPT— the missing field is the underlying defect, not a reason to scope down; (b) PERSIST bothprimary_domainANDprimary_subtopictocontent_itemsinflow.py declare_row(today neither is persisted on this path); (c) TIGHTENcontent_items.primary_domain+primary_subtopicto NOT NULL with an'unclassified'sentinel default — a migration is required (this corrects §4.1’s earlier “no DDL”). Pre-re-ingest sequencing means the fresh preview branch carries no legacy NULL rows, so the tightening is clean (no NULL backfill needed); (d) soft-warn (Inv-7) covers BOTHprimary_domainANDprimary_subtopic(+secondary_classifications[]); (e) surface sentinel ('unclassified') + out-of-taxonomy rows via the EXISTING/reviewtab + dashboard actionable-insights — NOT a new mechanism; tie the surfaced count to the Inv-7 flow-scope coverage metric (the taxonomy-miss counter). The original §5.1 deferral to T7/ID-45 and §4.1 “no database migration” are superseded by this entry; §3.7/§4.1/§5.1 below are amended to match.
1. Code-intelligence orientation (cited verbatim — re-run for {63.3} per Q-PLANNER-2; not inherited)
Section titled “1. Code-intelligence orientation (cited verbatim — re-run for {63.3} per Q-PLANNER-2; not inherited)”Per the .gitnexus/CLAUDE.md “Always Do” block, orientation was run before drafting. TS symbols via gitnexus (repo /Users/liamj/Documents/development/knowledge-hub); Python / markdown / SQL via grep + python3 import-and-compare (outside the TS index per .ast-dataflow/CLAUDE.md).
1.1 gitnexus_query({query: 'classifyContent'}) — verbatim
Section titled “1.1 gitnexus_query({query: 'classifyContent'}) — verbatim”Top execution flows: proc_245_get (app/api/cron/classification-quality/route.ts:GET 43-330) and proc_29_post (app/api/items/[id]/classify/route.ts:POST 27-125). Definitions returned: Function:lib/ai/classify.ts:classifyContent (1104-1832, module Ai), Function:lib/ai/classify.ts:coerceSubtopic (698-703), Interface:lib/ai/classify.ts:ClassificationResult (705-727). Scripts: scripts/reclassify-items.ts:main, scripts/backfill-classify-content-items.ts:main, scripts/eval-classification.ts:classifyFixtureItem. This is the app-side classify path — NOT the re-ingest path (confirmed: no cocoindex symbol surfaces under this query).
1.2 gitnexus_query({query: 'taxonomy snapshot'}) — verbatim (load-bearing for Inv-6/Inv-7/Inv-9)
Section titled “1.2 gitnexus_query({query: 'taxonomy snapshot'}) — verbatim (load-bearing for Inv-6/Inv-7/Inv-9)”processes: []. Definitions returned (relevant subset):
Function:scripts/tests/conftest.py:taxonomy_from_snapshot (96-104)Function:scripts/tests/conftest.py:valid_domains (108-110)Function:scripts/tests/conftest.py:valid_subtopics (114-116)Function:scripts/cocoindex_pipeline/extraction.py:_load_canonical_content_types (77-87)Function:scripts/cocoindex_pipeline/extraction.py:_load_canonical_form_types (93-123)Class:scripts/cocoindex_pipeline/extraction.py:FormMetadata (158-189)Function:scripts/generate-taxonomy-snapshot.ts:main (34-183)Method:scripts/tests/test_cocoindex_extraction.py:TestContentTypeParity.test_loaded_set_matches_snapshot_jsonMethod:scripts/tests/test_cocoindex_extraction.py:TestFormTypeParity.test_loaded_set_matches_snapshot_jsonFunction:scripts/mcp-eval/response-quality.ts:loadDomainNamesFromSnapshot (32-49)Load-bearing finding: scripts/tests/conftest.py already exposes session-scoped valid_domains (taxonomy_from_snapshot["domains"]) and valid_subtopics (taxonomy_from_snapshot["subtopics"]) fixtures (lines 108-117). The snapshot already carries domains[] and subtopics[] (verified §2). Therefore Inv-6 plumbing has a ready precedent — a Python _load_canonical_domains() in extraction.py mirrors _load_canonical_form_types exactly; the Inv-7 test can reuse the existing valid_domains fixture.
1.3 gitnexus_context({name: 'ClassificationExtraction', file_path: 'scripts/cocoindex_pipeline/extraction.py'}) — verdict + caller picture (Inv-5/Inv-6/Inv-7 blast radius)
Section titled “1.3 gitnexus_context({name: 'ClassificationExtraction', file_path: 'scripts/cocoindex_pipeline/extraction.py'}) — verdict + caller picture (Inv-5/Inv-6/Inv-7 blast radius)”uid Class:scripts/cocoindex_pipeline/extraction.py:ClassificationExtraction (252-277). Incoming imports (9 files): flow.py, pair_resolver.py, form_extractors/shared.py, + 6 test files (test_cocoindex_extraction.py, test_cocoindex_extractors.py, test_cocoindex_extractor_retry.py, test_cocoindex_flow_context.py, test_cocoindex_flow_idle_mode.py, test_cocoindex_app_main_retry_wiring.py). Properties: extraction_kind, content_type, primary_domain, classification_confidence, secondary_classifications, rationale — NO primary_subtopic property (§5.1). has_method: _validate_content_type. extends: _ExtractionBase. processes: []. Verdict: MEDIUM (9 importers, all in the cocoindex pipeline + its tests; no app/UI fan-out). The Inv-7 implementation Subtask MUST run gitnexus_impact({target: 'ClassificationExtraction', direction: 'upstream'}) before editing and run the full cocoindex pytest suite (8 importing test files) before commit.
1.4 gitnexus_context({name: '_load_canonical_form_types', file_path: 'scripts/cocoindex_pipeline/extraction.py'}) — verdict
Section titled “1.4 gitnexus_context({name: '_load_canonical_form_types', file_path: 'scripts/cocoindex_pipeline/extraction.py'}) — verdict”uid Function:scripts/cocoindex_pipeline/extraction.py:_load_canonical_form_types (93-123). Incoming: only the module file itself (module-load side-effect populating _VALID_FORM_TYPES). outgoing: {}, processes: []. Verdict: LOW. Adding sibling _load_canonical_domains / _load_canonical_subtopics loaders is a low-blast-radius mirror change.
1.5 grep sweeps (Python / markdown / SQL — outside the TS index)
Section titled “1.5 grep sweeps (Python / markdown / SQL — outside the TS index)”scripts/cocoindex_pipeline/prompts.py—Q_A_FORM_PROMPTnames 11 form_types at line 104 (FIELD CONSTRAINTS) AND repeatsframework / dps / gcloudat line 114 (GUIDANCE). The module docstring lines 16-17 also asserts “11-valueform_typeLiteral”.CLASSIFICATION_PROMPTcontent_type list at lines 55-56.scripts/cocoindex_pipeline/flow.py:1076—classification = await extract_classification(content_text);ci_target.declare_rowat 1115-1123 writescontent_itemswith onlyid, content_text, embedding, source_document_id, op_id—primary_domainis NOT persisted tocontent_itemstoday (§5.1). Flow-scope counters useflow_context_module.current_stage_counter()(line 1134) — the Inv-7 surface attaches here.docs/ontology/26-form-type.md— frontmatterbaseline_values= 8 (correct, lines 10-42); residual prose drift at Definition (line 49), Q-OQR1 note (line 51), and## Baseline valuesprose table (lines 66-78, 11 rows) (§3.8). The Synonyms section (lines 53-62) legitimately retains DPS / G-Cloud as acronym definitions — those are aliases, NOT form_type values; they stay.docs/ontology/12-requirement-type.md— frontmatterbaseline_values= 7;editable_via: admin_ui(line 6 — material for Inv-9, §5.2).__tests__/lib/ontology/markdown-parity.test.ts—SNAPSHOT_KEY_BY_CV_NAME(lines 46-49) maps onlycontent_type/platform; the parity loop filterscv.editable_via === 'database_migration'(line 84); theTaxonomySnapshotTS interface (lines 30-33) declares onlycontent_types?/platforms?.- SQL:
form_template_requirements_requirement_type_checkenforces exactly('policy','statement','evidence','data','narrative','declaration','reference')(migration20260416102457:4167, renamed20260520120828:242).
2. Pre-ratification empirical verification (OQ-3 / Q-EX2 forcing function)
Section titled “2. Pre-ratification empirical verification (OQ-3 / Q-EX2 forcing function)”External + internal symbols cited in this TECH were checked against the installed pins in this worktree (off main). The Q-EX2 precedent (cocoindex 1.0.0 removed ExtractByLlm; drift propagated unchecked) is the failure mode this block prevents.
| Date | Pin | Symbol / artefact checked | Result |
|---|---|---|---|
| 29/05/2026 | cocoindex[postgres]==1.0.3 | scripts/cocoindex_pipeline/extraction.py imports cocoindex as coco, @coco.fn(memo=True); module imports cleanly | PRESENT (ExtractByLlm known-ABSENT and avoided per {52.1}) |
| 29/05/2026 | pydantic==2.12.5 | field_validator, model_validator, ConfigDict(strict=True, extra='forbid'), ValidationError.errors()[0]['type'] strings (value_error, literal_error) | PRESENT — _PYDANTIC_ERROR_TO_ERROR_CLASS (extraction.py:295-322) empirically maps value_error → invalid_enum; the Inv-7 soft-warn validator (a model_validator(mode='after')) does NOT raise, so it does not interact with this map |
| 29/05/2026 | anthropic==0.79.0 | ANTHROPIC_MODEL = "claude-opus-4-6" (extraction.py:65) | PRESENT (unaffected by this spec) |
| 29/05/2026 | gray-matter ^4.0.3 | lib/ontology/loader.ts:loadOntologyCVs frontmatter parse | PRESENT |
| 29/05/2026 | zod ^4.4.3 | lib/ontology/schemas.ts:OntologyCVSchema (.strict(), .superRefine) | PRESENT |
Ground-truth checks (this worktree, run during {63.3} authoring):
- Snapshot inventory (
scripts/tests/fixtures/taxonomy_snapshot.json, top-level keyscontent_types, domains, form_types, generated_at, platforms, subtopics):content_types= 15 (list of plain strings);form_types= 8 (list of{key,label}objects);domains= list of{id, name, display_order, colour, provenance}objects (namee.g.security);subtopics= list of{id, domain_id, name, display_order, provenance, description}objects (namee.g.functionality, domain-scoped viadomain_id); norequirement_type/requirement_typeskey (grep -c→ 0). Confirms Inv-6 readsdomains[].name/subtopics[].name; Inv-9 must ADD the snapshot key. - Inv-1/Inv-2 form_type byte-diff (empirical
python3import-and-compare):Q_A_FORM_PROMPTform_type set = 11 (bid, checklist, dps, framework, gcloud, itt, pqq, questionnaire, rfp, sales_proposal_template, tender);extraction.py:_VALID_FORM_TYPES(snapshot-loaded) = 8;{prompt} − {validator}={dps, framework, gcloud};{snapshot} − {prompt}={}. → SHRINK prompt to 8 removes exactlydps/framework/gcloud; after the fix{prompt} ⊆ _VALID_FORM_TYPESholds.SIGNATURE_DRIFTconfirmed (prompt-contract ↔ validator) — this is the in-repo defect Inv-1/2/3 fix, NOT an external-API drift. - Inv-4 content_type byte-diff (empirical):
CLASSIFICATION_PROMPTcontent_type set == snapshotcontent_typesexactly (both directions empty). → Inv-4 is a regression guard; no prompt edit needed (PRESENT/aligned). - Inv-3 lock-state baseline:
python3 -m pytest scripts/tests/test_cocoindex_prompts.py→ 19 passed.test_q_a_form_enumerates_form_types(lines 112-130) asserts the legacy 11-value literal with docstring “should list the 11 canonical form_type values” — the GREEN-locked stale contract Inv-3 corrects. - Inv-8 baseline:
bun run test __tests__/lib/ontology/→ 2 files, 12 tests, all pass.form-type-parity.test.ts:72reads frontmatterbaseline_valuesonly (NOT prose) → the prose-table drift is genuinely unguarded. - Inv-9
editable_via:12-requirement-type.md:6=editable_via: admin_ui(NOTdatabase_migration). The existing parity loop (markdown-parity.test.ts:84) would skip it. → Inv-9 needs a guard that does NOT gate oneditable_via(§3.9, §5.2). - Inv-9 live CHECK:
form_template_requirements_requirement_type_check= exactly the 7 values, matching12-requirement-type.mdfrontmatter (7) and prose table (7) today. Behaviour PRESENT/aligned; guard ABSENT.
No ABSENT or SIGNATURE_DRIFT against an external pin blocks ratification. The one SIGNATURE_DRIFT (prompt ↔ validator form_type) is the in-repo defect this Task fixes. Ratification of this TECH proceeds.
3. Proposed changes — one subsection per PRODUCT invariant
Section titled “3. Proposed changes — one subsection per PRODUCT invariant”Mapping is one-to-one: §3.N satisfies
Inv-N. Each subsection names the file(s), the symbol(s), the change, and the acceptance hook the{63.4}Subtask’stestStrategywill carry. KH quality bars (UK English;bun run test/python3 -m pytest; tests verify behaviour perdocs/reference/test-philosophy.md; no barrel re-exports;sb()/tryQuery()for any new Supabase read) apply throughout per §3.12.
3.1 → Inv-1 — Q_A_FORM_PROMPT enumerates exactly the canonical 8 form_type set
Section titled “3.1 → Inv-1 — Q_A_FORM_PROMPT enumerates exactly the canonical 8 form_type set”File: scripts/cocoindex_pipeline/prompts.py. Symbol: Q_A_FORM_PROMPT (+ module docstring).
Change (shrink to 8 at three sites — empirically the only sites naming form_type values):
- Line 104 (FIELD CONSTRAINTS): replace
MUST be ONE of: bid, rfp, pqq, itt, tender, framework, dps, gcloud, checklist, questionnaire, sales_proposal_template.with the 8-value listbid, rfp, pqq, itt, tender, checklist, questionnaire, sales_proposal_template.— removeframework, dps, gcloud. - Line 114 (GUIDANCE): replace
Choose \bid` / `rfp` / `pqq` / `itt` / `tender` / `framework` / `dps` / `gcloud` for procurement forms;withChoose `bid` / `rfp` / `pqq` / `itt` / `tender` for procurement forms;` — remove the three retired values from the guidance clause. - Lines 16-17 (module docstring): change “the 11-value
form_typeLiteral” to “the 8-valueform_typeset (snapshot-backed; seeextraction.py:_VALID_FORM_TYPES)”. (Also noteform_typeis no longer aLiteral— it is astr+field_validatorper{52.6}; correct the docstring’s stale “Literal” wording.)
Do NOT touch: ENTITY_MENTION_PROMPT (its entity_type list legitimately contains framework — a different vocabulary, PRODUCT Inv-1 carve-out). The Synonyms acronyms in 26-form-type.md are a separate file (§3.8).
Acceptance: every form_type value named in Q_A_FORM_PROMPT ∈ _VALID_FORM_TYPES; framework/dps/gcloud absent from Q_A_FORM_PROMPT’s form_type enumeration (asserted by the §3.3 test).
3.2 → Inv-2 — no prompt-named form_type is rejected by the validator
Section titled “3.2 → Inv-2 — no prompt-named form_type is rejected by the validator”No production change beyond §3.1 — Inv-2 is the consequence of §3.1 and is enforced by a test, not a code path. After §3.1, {form_types named in Q_A_FORM_PROMPT} − _VALID_FORM_TYPES = ∅.
File (test): scripts/tests/test_cocoindex_prompts.py. Change: add a method to TestPromptsEnumeratesEnums (or fold into the §3.3 rewrite) — test_q_a_form_named_form_types_are_validator_accepted: parse the form_type set out of Q_A_FORM_PROMPT, import _VALID_FORM_TYPES from scripts.cocoindex_pipeline.extraction, assert prompt_set <= _VALID_FORM_TYPES. Parser: the FIELD-CONSTRAINTS line is machine-parseable (re.search(r'form_metadata\.form_type: MUST be ONE of:\s*(.+?)\.', Q_A_FORM_PROMPT, re.S) then split on ,) — empirically verified during {63.3} authoring.
Acceptance: the subset assertion passes (rejected-subset empty); a genuine framework/DPS/G-Cloud q_a_form no longer fails form_type validation because the prompt was steered toward a retired value.
3.3 → Inv-3 — the form_type prompt lock asserts canonical behaviour, not a frozen list
Section titled “3.3 → Inv-3 — the form_type prompt lock asserts canonical behaviour, not a frozen list”File: scripts/tests/test_cocoindex_prompts.py. Symbol: TestPromptsEnumeratesEnums::test_q_a_form_enumerates_form_types (lines 112-130).
Change (rewrite the test to derive its expectation from the snapshot — the single source of truth):
- Replace the hardcoded
form_types = {…11 literals…}set with a snapshot-derived set. Two equivalent options; pick (a) for minimal new surface:- (a) Reuse
_VALID_FORM_TYPES:from scripts.cocoindex_pipeline.extraction import _VALID_FORM_TYPES(already snapshot-loaded), then assert every member appears inQ_A_FORM_PROMPTAND the prompt names no form_type outside it (combine with §3.2 into a single bidirectional assertion). - (b) Load the snapshot directly: read
scripts/tests/fixtures/taxonomy_snapshot.json(thetaxonomy_from_snapshotconftest fixture already does this) and build{r['key'] for r in snapshot['form_types']}.
- (a) Reuse
- Docstring: remove the “11 canonical” count; reword to “Q_A_FORM_PROMPT must enumerate exactly the snapshot-backed canonical form_type set (no frozen literal).” Reads as a product spec per
docs/reference/test-philosophy.md§1 criterion 5. - Drift-tracking acceptance: the test must track a snapshot mutation without a manual edit. The {63.4} test includes a sub-assertion using a temp/monkeypatched snapshot copy (write a
tmp_pathsnapshot with an added/removed form_type, reload, assert the expectation moves) — proving the expectation is data-derived, not literal. (Mechanism: parametrise the loader path, ormonkeypatch_TAXONOMY_SNAPSHOT_PATH+ re-call_load_canonical_form_types.)
Acceptance: the literal-11 assertion is gone; the test loads its expectation from the snapshot; mutating the snapshot’s form_types in a temp copy moves the expectation; the test passes against the current 8-key snapshot.
3.4 → Inv-4 — CLASSIFICATION_PROMPT content_type parity (regression guard; already aligned)
Section titled “3.4 → Inv-4 — CLASSIFICATION_PROMPT content_type parity (regression guard; already aligned)”File (test): scripts/tests/test_cocoindex_prompts.py. Symbol: TestPromptsEnumeratesEnums::test_classification_enumerates_content_types (lines 94-110).
Change (tighten the existing weak guard into a bidirectional snapshot-parity guard): the current test only checks that 8 “core” values are present (a subset check). Replace with an exact bidirectional assertion: parse the content_type set out of CLASSIFICATION_PROMPT (FIELD-CONSTRAINTS line content_type: MUST be ONE of the following canonical values:\n (.+?)\.), compare against _VALID_CONTENT_TYPES (or snapshot['content_types']); assert both prompt − snapshot = ∅ and snapshot − prompt = ∅; report both directions.
No prompt edit — empirically prompt == snapshot today (§2). This is a pure regression guard that fails loudly on future drift in either direction.
Acceptance: the bidirectional assertion passes today; fails if a content_type is added to the snapshot but not the prompt (or vice versa); both directions reported.
3.5 → Inv-5 — content_type stays hard-validated on the re-ingest path (no regression)
Section titled “3.5 → Inv-5 — content_type stays hard-validated on the re-ingest path (no regression)”File: scripts/cocoindex_pipeline/extraction.py. Symbol: ClassificationExtraction._validate_content_type (lines 267-278). Change: NONE — this invariant is a guard against regression introduced by the §3.6/§3.7 domain work. The Inv-7 soft-warn mechanism is a separate model_validator(mode='after') (§3.7) that does NOT touch content_type’s field_validator.
File (test): scripts/tests/test_cocoindex_extraction.py (the ClassificationExtraction suite). Change: confirm/retain a test asserting an out-of-vocabulary content_type (e.g. "junk") raises ValidationError mapped to invalid_enum by classify_pydantic_error. If absent, add it; if present, the {63.4} Subtask references it as the Inv-5 acceptance witness (no new code).
Acceptance: an out-of-vocabulary content_type raises a Pydantic ValidationError that classify_pydantic_error() maps to invalid_enum; the §3.7 change does not weaken this.
3.6 → Inv-6 — domain/subtopic vocabulary sourced from the live taxonomy (snapshot-backed plumbing)
Section titled “3.6 → Inv-6 — domain/subtopic vocabulary sourced from the live taxonomy (snapshot-backed plumbing)”File: scripts/cocoindex_pipeline/extraction.py. New symbols (mirror _load_canonical_form_types, §1.4 verdict LOW):
def _load_canonical_domains() -> frozenset[str]: """Read the canonical domain names from the taxonomy snapshot.
Mirrors `_load_canonical_form_types`. The snapshot's `domains[]` is regenerated from live `taxonomy_domains` by `bun run sync:taxonomy`; the TS app path's equivalent live-taxonomy read is `classify.ts:1150`. """ with _TAXONOMY_SNAPSHOT_PATH.open() as fh: snapshot = json.load(fh) domains = snapshot.get("domains") if not isinstance(domains, list) or not domains: raise ValueError(f"taxonomy_snapshot.json missing 'domains' array — path={_TAXONOMY_SNAPSHOT_PATH}") names = [row.get("name") for row in domains if isinstance(row, dict)] if not all(isinstance(n, str) and n for n in names): raise ValueError("taxonomy_snapshot.json domains entry missing string 'name'") return frozenset(names)
_VALID_DOMAINS: frozenset[str] = _load_canonical_domains()§0.4 amendment. The original parenthetical here said
subtopicsare deferred (loader built “only if/when a subtopic field is added”). Per §0.4, the subtopic field IS added in this Task, so_load_canonical_subtopics()is built now as a one-line mirror of_load_canonical_domains()(readingsnapshot["subtopics"][].name→ flatfrozenset(_VALID_SUBTOPICS); subtopic names are globally unique, §5.1). Both_VALID_DOMAINSand_VALID_SUBTOPICSare bound at module load and used by the §3.7 soft-warn.
Reuse rather than duplicate: the test side reuses scripts/tests/conftest.py:valid_domains (already taxonomy_from_snapshot["domains"]) — mapping [d["name"] for d in valid_domains].
Do NOT hardcode any domain list in prompts.py or extraction.py. The CLASSIFICATION_PROMPT primary_domain line (line 57) describes the field as a free-form snake_case identifier with examples — leave the prose examples as guidance, but the enforcement set comes from _VALID_DOMAINS (snapshot), satisfying “MUST NOT hardcode a domain/subtopic list that can drift”.
Acceptance: the valid-domain set the re-ingest path checks against is _VALID_DOMAINS, loaded from snapshot.domains[].name; a test asserts _VALID_DOMAINS == {d['name'] for d in snapshot['domains']} and that no literal domain list is enforced in prompts.py/extraction.py.
3.7 → Inv-7 — soft-warn + coverage on out-of-taxonomy primary_domain AND primary_subtopic (RATIFIED soft-warn; mirrors Inv-17; §0.4-extended to subtopic + persistence + sentinel + surfacing)
Section titled “3.7 → Inv-7 — soft-warn + coverage on out-of-taxonomy primary_domain AND primary_subtopic (RATIFIED soft-warn; mirrors Inv-17; §0.4-extended to subtopic + persistence + sentinel + surfacing)”§0.4 amendment (OQ-63-9 ratified). Per §0.4, this invariant’s scope is widened from the original “
primary_domainonly / defer subtopic” to cover BOTHprimary_domainANDprimary_subtopic(+secondary_classifications[]), with both fields PERSISTED tocontent_items(aprimary_subtopicfield is added toClassificationExtraction+ the prompt; both persisted inflow.py declare_row), the columns TIGHTENED to NOT NULL +'unclassified'sentinel (migration — §4.1), and the sentinel/out-of-taxonomy rows SURFACED via the existing/reviewtab + dashboard actionable-insights tied to this counter. The_load_canonical_subtopicsloader is no longer “future” — it is built in this Task (§3.6’s deferral note is superseded). The{63.4}PLAN authors the slicing:{63.5}–{63.10}plus the OQ-63-9-added migration + surfacing slices{63.11}–{63.12}(see §6 + this TECH’s reconcile stamp).
This is the chain-dependent slice — depends on §3.6 (_VALID_DOMAINS + _VALID_SUBTOPICS). Mechanism, mirroring the existing flow-scope counter pattern (flow_context.current_stage_counter() / current_retry_counter() / the embedding-counter bump at flow.py:1134-1136):
(i) scripts/cocoindex_pipeline/extraction.py — add a non-raising post-validation check to ClassificationExtraction (NOT a field_validator that raises — that would hard-reject):
@model_validator(mode="after")def _surface_out_of_taxonomy_domain(self) -> "ClassificationExtraction": # SOFT-WARN (S279 ratified): do NOT raise, do NOT coerce. Record a # surfaced taxonomy-miss so a corpus-wide re-ingest produces a # measurable signal. Mirrors the Inv-17 graceful-with-surfaced-reason # shape (flow.py embedding/retry counters). Lazy import via __package__ # keeps ContextVar identity consistent across the dual import paths # (same rationale as stamp_extraction_base). if self.primary_domain not in _VALID_DOMAINS: from importlib import import_module fc = import_module(f"{__package__}.flow_context") counter = fc.current_taxonomy_miss_counter() # new dimension, §3.7(ii) if counter is not None: counter.record(field="primary_domain", value=self.primary_domain) # structured warning via logging (no PII; value is a snake_case slug) logging.getLogger(__name__).warning( "out-of-taxonomy primary_domain=%r (valid set has %d entries) — " "row written (soft-warn per ID-63 Inv-7)", self.primary_domain, len(_VALID_DOMAINS), ) return selfOptionally also check each secondary_classifications[] value (the prompt calls them “secondary domain names”) and record misses the same way — recommended (cheap, same loop), but Liam may scope Inv-7 to primary_domain only at this gate. The row is always returned unchanged — no drop, no coercion.
(ii) scripts/cocoindex_pipeline/flow_context.py — add a taxonomy-miss counter dimension mirroring the existing current_stage_counter / current_retry_counter ContextVar + bind-context pattern. Either: (a) a new TaxonomyMissCounter ContextVar with bind_taxonomy_miss_counter() + current_taxonomy_miss_counter(), recording {field, value} → count; OR (b) extend the existing stage-counter with a taxonomy_miss dimension. (a) is cleaner (keeps stage counts numeric); follow the dual-import-path discipline (importlib.import_module(f"{__package__}...")) used by every existing counter.
(iii) scripts/cocoindex_pipeline/flow.py — surface the counter in the flow-end webhook. app_main already binds counters (bind_stage_counter, bind_retry_counter) and emits them in the rollup webhook. Bind the taxonomy-miss counter the same way and include its tally in the webhook payload (the “coverage metric” the operator inspects post-re-ingest). The bump itself happens inside extract_classification’s validation (which runs under the flow binding via ingest_file), so no per-call wiring at the flow.py:1076 call-site is needed beyond the app_main bind.
(iv) coerceSubtopic/validateDomain are NOT replicated. Explicitly: the TS validateDomain coerce-to-validDomains[0] fallback (classify.ts:620-629) is the smell PRODUCT Inv-7 names; the cocoindex path records-and-keeps instead.
Acceptance: an extraction whose primary_domain ∉ _VALID_DOMAINS (OR whose non-null primary_subtopic ∉ _VALID_SUBTOPICS, OR any secondary_classifications[] value ∉ _VALID_DOMAINS) is written (model validates successfully — model_validator returns the instance) AND increments the flow-scope taxonomy-miss counter (with the correct field ∈ {primary_domain, primary_subtopic, secondary_classification}) AND emits a structured warning; valid values produce no warning and no counter bump; the counter tally (broken down by field) appears in the flow-end webhook payload. Both primary_domain and primary_subtopic are persisted to content_items (a row missing a value lands the 'unclassified' sentinel per §4.1). The sentinel / out-of-taxonomy rows are queryable via the existing /review tab + dashboard actionable-insights (§0.4(e)). A regression test (Inv-5) confirms content_type hard-reject is unaffected.
Liam confirms the enforcement mode at this TECH gate. This TECH encodes the S279-ratified soft-warn. If Liam flips to hard-reject, the change inverts to a raising
field_validatoronprimary_domain(mapped to a newinvalid_domainerror class in_PYDANTIC_ERROR_TO_ERROR_CLASS) and §3.7(ii)/(iii) are dropped — surface as an OQ if re-opened. As ratified, soft-warn stands.
3.8 → Inv-8 — 26-form-type.md prose ↔ frontmatter ↔ snapshot lockstep (remediate prose to 8 + guard)
Section titled “3.8 → Inv-8 — 26-form-type.md prose ↔ frontmatter ↔ snapshot lockstep (remediate prose to 8 + guard)”File (remediation): docs/ontology/26-form-type.md. Change (in-Task per PRODUCT §Open-questions recommended default — fix + guard ship together):
## Baseline valuesprose table (lines 66-78): delete the three rowsframework | Framework | core,dps | DPS (Dynamic Purchasing System) | core,gcloud | G-Cloud | core→ table becomes the 8 rows matching frontmatter.## Definition(line 49): reword “Procurement forms (bid, RFP, PQQ, ITT, tender, framework, DPS, G-Cloud) are CORE…” to “Procurement forms (bid, RFP, PQQ, ITT, tender) are CORE…”; note that framework / DPS / G-Cloud now live in the sibling CVsprocurement_vehicle(30) /procurement_vehicle_instance(31).- Q-OQR1 note (line 51): reword “
form_typediscriminatesbid/rfp/pqq/itt/tender/framework/dps/gcloud” to the 5 procurement form_types (drop the three vehicles). - Do NOT touch: the Synonyms section (lines 53-62) — DPS / G-Cloud are legitimate acronym aliases there. The “Planned 3-tier taxonomy split” section (lines 110-128) already lists the correct trimmed 8 (line 116); optionally restamp its “Status: Planned” → “Status: shipped (T2, S246/S247)” for accuracy, but that is not load-bearing for the guard.
File (guard): __tests__/lib/ontology/form-type-parity.test.ts (extend) — add an it() that reads 26-form-type.md raw via readFileSync (NOT loadOntologyCVs, which parses frontmatter only), parses the ## Baseline values markdown table rows (regex on | key | label | provenance | body rows under that heading), and asserts the prose-table key set equals the frontmatter baseline_values key set (and therefore the snapshot 8). Report drift both ways. This closes the unguarded prose surface (RESEARCH R6 / PRODUCT §Form-type-resolution NEW finding).
Acceptance: after remediation, frontmatter baseline_values (8) == prose ## Baseline values table (8) == snapshot form_types (8); the new guard fails if the prose table lists a form_type absent from frontmatter (or vice versa).
3.9 → Inv-9 — requirement_type markdown ↔ snapshot ↔ DB CHECK parity guard (add snapshot key; guard not gated on editable_via)
Section titled “3.9 → Inv-9 — requirement_type markdown ↔ snapshot ↔ DB CHECK parity guard (add snapshot key; guard not gated on editable_via)”Three coordinated changes (chain: snapshot-key first, then guard):
(i) scripts/generate-taxonomy-snapshot.ts — add a requirement_type array to the snapshot. The opaque get_check_constraint_values RPC currently surfaces only content_type + platform rows (lines 105-108 filter on those). The robust pattern (mirrors content_type’s RPC-primary-plus-hardcoded-fallback, lines 101-131): extend the RPC consumption to also pick up a requirement_type row if present (if (row.column_name === 'requirement_type') requirementTypes = row.allowed_values;), with a hardcoded fallback mirror of the 7 live values when the RPC omits it. Add requirement_type: requirementTypes to the snapshot object (line 138-161) and a console.log count. Regenerate via bun run sync:taxonomy (the Executor with DB access regenerates; the committed fixture gains the key).
If the live
get_check_constraint_valuesRPC scans all CHECK constraints (it readsinformation_schemaserver-side per the generator comment line 98-99), therequirement_typerow may already be returned — the Executor verifies via a one-off RPC call and adjusts the fallback accordingly. Either way the fallback guarantees the key lands.
(ii) __tests__/lib/ontology/markdown-parity.test.ts — extend the TaxonomySnapshot interface + add a dedicated requirement_type parity case that does NOT gate on editable_via. Per §5.2, 12-requirement-type.md is editable_via: admin_ui, so the existing databaseMigrationCVs loop (line 84) would skip it — adding it to SNAPSHOT_KEY_BY_CV_NAME alone is insufficient. Instead add a standalone it('requirement_type markdown baseline_values match the snapshot requirement_type set and the live DB CHECK both ways'):
- extend
interface TaxonomySnapshot(lines 30-33) withrequirement_type?: string[];; const reqCV = cvs.find(cv => cv.cv_name === 'requirement_type');- assert
reqCV.baseline_values.map(bv => bv.key)(sorted)==snapshot.requirement_type(sorted), both directions reported; - the snapshot is itself DB-derived (regenerated from the live CHECK by §3.9(i)), so markdown == snapshot == DB CHECK transitively, satisfying “the markdown ↔ DB CHECK direction is genuinely covered (not skipped by the
editable_viafilter)”.
(Optionally also wire requirement_type → 'requirement_type' into SNAPSHOT_KEY_BY_CV_NAME for documentation, but the standalone case is the load-bearing guard given the admin_ui filter.)
(iii) Python side (optional, parity with content_type/form_type): scripts/tests/test_cocoindex_extraction.py has TestContentTypeParity + TestFormTypeParity. A TestRequirementTypeParity (snapshot requirement_type non-empty + 7 values) is cheap and consistent, but NOT required — requirement_type has no Python consumer in extraction.py (Path-C unbuilt). Include only if the {63.4} slice budget allows; the TS guard (ii) is the deliverable.
Acceptance: the snapshot exposes a requirement_type value set (7); a test asserts 12-requirement-type.md baseline_values keys == snapshot requirement_type == live DB CHECK values, failing on any drift; the case runs regardless of editable_via: admin_ui.
3.10 → Inv-10 — lockstep enforced by in-repo test/CI guards, decoupled from ID-9 docubot
Section titled “3.10 → Inv-10 — lockstep enforced by in-repo test/CI guards, decoupled from ID-9 docubot”No new code — this is a constraint on the §3.1-§3.9 guards: every new/extended guard lands in scripts/tests/test_cocoindex_prompts.py, scripts/tests/test_cocoindex_extraction.py, __tests__/lib/ontology/form-type-parity.test.ts, __tests__/lib/ontology/markdown-parity.test.ts — all run by bun run test / python3 -m pytest in the existing CI jobs (quality-test 4-shard Vitest matrix + the Python pytest job per docs/runbooks/ci.md). None imports or invokes the ID-9 Astro/docubot docs-site sync (docs-site/ / sync-content.ts).
Acceptance: the new guards run in the standard suites and gate PRs via existing CI; grep confirms no Inv-1…Inv-9 guard imports the docs-site sync; removing the docs-site sync would not affect any guard.
3.11 → Inv-11 — the canonical re-ingest classifier is unambiguous and singular
Section titled “3.11 → Inv-11 — the canonical re-ingest classifier is unambiguous and singular”No code change — a declaration the TECH records and the guards honour: scripts/cocoindex_pipeline/extraction.py (driven by flow.py, writing content_items / q_a_extractions via mount_table_target) is the canonical re-ingest classifier; the §3.1-§3.9 invariants reference only extraction.py + prompts.py for re-ingest CV correctness. lib/ai/classify.ts is unmodified (canonical for interactive/UI ingest); scripts/kb_pipeline/classify.py is legacy (retirement = follow-up, §7). The {63.4} PLAN and Executor briefs must reference no second re-ingest classifier.
Acceptance: the spec/PLAN names exactly one re-ingest classifier; no change in this Task alters lib/ai/classify.ts behaviour (gitnexus_detect_changes on the Executor branch shows no classify.ts edit).
3.12 → Inv-12 — KH quality bars hold for every artefact this Task produces
Section titled “3.12 → Inv-12 — KH quality bars hold for every artefact this Task produces”Applies to all of §3.1-§3.9:
- UK English — colour, organise, behaviour, DD/MM/YYYY (the prose remediation §3.8 and every docstring).
- Tests verify behaviour, not implementation — directly load-bearing for §3.3 (Inv-3 corrects a test that asserted an implementation literal); the rewritten test reads as a product spec per
docs/reference/test-philosophy.md. - No new Supabase read in hooks — the only Supabase touch is
generate-taxonomy-snapshot.ts(§3.9(i)), a build-time script already using@supabase/supabase-jsservice-role client; nosb()/tryQuery()change needed there (it is not an app hook). No new app-side DB read is introduced. - No barrel re-exports — Python imports are direct module imports; the TS guard imports
@/lib/ontology/loaderdirectly (existing pattern). - Exactly one DDL migration (OQ-63-9 / §4.1) — the
content_items.primary_domain+primary_subtopicNOT-NULL +'unclassified'sentinel tightening, viasupabase migration new+db push(link-verify first). The liverequirement_typeCHECK already exists (§3.9 only reads it). No PL/pgSQL function is added (theSET search_pathgotcha does not apply). The Inv-7 taxonomy-membership soft-warn remains application-level (no CHECK constraint enforces taxonomy membership).Original “No DDL required” is superseded by §4.1. - Test commands —
bun run test(notbun test) for Vitest;python3 -m pytest scripts/tests/for Python.
Acceptance: a reviewer confirms each artefact against these bars; the §3.3 test reads as canonical-behaviour prose, not a frozen literal.
4. Migration / build plan
Section titled “4. Migration / build plan”4.1 Database migration (OQ-63-9 — content_items NOT-NULL + sentinel tightening; SUPERSEDES the original “no DDL”)
Section titled “4.1 Database migration (OQ-63-9 — content_items NOT-NULL + sentinel tightening; SUPERSEDES the original “no DDL”)”§0.4 amendment. This section originally read “No database migration”. That is superseded by the OQ-63-9 ratification: ONE migration is required (the only DDL in this Task).
- Inv-9 still reads the live
form_template_requirements_requirement_type_check(7 values) into the snapshot — no DDL there. Thecontent_type/form_typessources are unchanged. - OQ-63-9 (Inv-7 persistence) requires ONE migration —
supabase migration new oq_63_9_content_items_classification_not_null(thensupabase migration upon the preview branch;db pushafter link-verify per the CLAUDE.md gotcha): tightencontent_items.primary_domainandcontent_items.primary_subtopicfrom nullable toNOT NULL DEFAULT 'unclassified'. The'unclassified'sentinel is the value the soft-warn / persistence path lands when no in-taxonomy value is available, so a missing classification is a queryable signal (via/review+ dashboard) rather than a NULL hole.- Pre-re-ingest sequencing makes this clean. Per §0.4, the migration lands on a fresh preview branch with no legacy NULL rows (the corpus is re-ingested after, not migrated in place). So the
SET DEFAULT 'unclassified'+SET NOT NULLneeds no NULL backfill on a populated table. (If the Executor finds the target branch is NOT clean — i.e. existing rows have NULLprimary_domain/primary_subtopic— STOP and escalate: aUPDATE ... SET primary_domain = 'unclassified' WHERE primary_domain IS NULLbackfill step must precedeSET NOT NULL, and the pre-re-ingest assumption is violated.) - DDL via CLI only (
supabase migration new+db push), never MCPexecute_sql; no PL/pgSQL function is added so theSET search_pathgotcha does not apply here.
- Pre-re-ingest sequencing makes this clean. Per §0.4, the migration lands on a fresh preview branch with no legacy NULL rows (the corpus is re-ingested after, not migrated in place). So the
- Inv-7 soft-warn logic itself is application-level (the
model_validator+ counter) — no constraint enforces taxonomy membership at the DB; the NOT-NULL constraint only enforces presence (sentinel-or-value), consistent with the ratified “write, don’t reject” shape.
(Stated explicitly so the Executor lands exactly one migration — the content_items NOT-NULL + sentinel — and invents no other.)
4.2 Build / regeneration step (Inv-9 only)
Section titled “4.2 Build / regeneration step (Inv-9 only)”After §3.9(i) edits generate-taxonomy-snapshot.ts, regenerate the committed fixture (the Executor with staging DB access runs this; the worktree inherits no Supabase link):
# Verify link FIRST per CLAUDE.md gotcha (worktree inherits no link state):supabase link --project-ref turayklvaunphgbgscat # staging — first actioncat supabase/.temp/project-ref # MUST read turayklvaunphgbgscatbun run sync:taxonomy # regenerates taxonomy_snapshot.json (+ requirement_type key)sync:taxonomy = generate-classification-prompt-taxonomy.ts && generate-taxonomy-snapshot.ts && sync-plugin-taxonomy.ts && build:plugin (package.json:31). The committed scripts/tests/fixtures/taxonomy_snapshot.json diff (new requirement_type array) is part of the Inv-9 Subtask’s deliverable. Guard tests (mcp-fixture-sync.test.ts / doc-freshness.test.ts) may react to the snapshot change — the Executor runs bun run test post-regen and updates any reacting fixture (CLAUDE.md “Guard tests break on structural changes” gotcha).
4.3 Chain-dependencies (drives {63.4} sequencing)
Section titled “4.3 Chain-dependencies (drives {63.4} sequencing)”§3.6 (_VALID_DOMAINS plumbing) ──→ §3.7 (Inv-7 soft-warn uses _VALID_DOMAINS + flow_context counter)§3.9(i) (snapshot requirement_type key) ──→ §3.9(ii) (TS parity guard reads the key)§3.1 (shrink prompt) ──→ §3.2/§3.3 (tests assert against the shrunk prompt + snapshot)§3.8 prose remediation ──→ §3.8 guard (guard would fail until prose is fixed → land together in one slice)All other invariants are independent. §3.4 (Inv-4 guard), §3.5 (Inv-5 witness), §3.10/§3.11 (declarations) carry no chain.
4.4 Test-suite touch-points (full-regression after merge)
Section titled “4.4 Test-suite touch-points (full-regression after merge)”- Python:
python3 -m pytest scripts/tests/test_cocoindex_prompts.py scripts/tests/test_cocoindex_extraction.py(Inv-1..Inv-7 Python side) + fullpython3 -m pytest scripts/tests/regression (theClassificationExtractionchange is MEDIUM-blast across 8 importing test files per §1.3). - TS:
bun run test __tests__/lib/ontology/(Inv-8, Inv-9) +bun run testfull regression (snapshot change may ripple to guard tests per §4.2). - The Inv-7 Executor MUST run
gitnexus_impact({target: 'ClassificationExtraction', direction: 'upstream'})before editing andgitnexus_detect_changes()before commit (Inv-3/Inv-11 propagation discipline;.gitnexus/CLAUDE.md“Always Do”).
5. New TECH-surfaced findings (not blockers; recorded for PLAN + Liam)
Section titled “5. New TECH-surfaced findings (not blockers; recorded for PLAN + Liam)”5.1 The cocoindex ClassificationExtraction shape had NO primary_subtopic field — FIXED in this Task (OQ-63-9 resolved-via-fix; was Inv-7 scoping deferral)
Section titled “5.1 The cocoindex ClassificationExtraction shape had NO primary_subtopic field — FIXED in this Task (OQ-63-9 resolved-via-fix; was Inv-7 scoping deferral)”§0.4 amendment — SUPERSEDES this section’s original “defer subtopic to T7/ID-45 / domain-only v1” stance. The original text below treated the missing
primary_subtopicfield as a reason to scope Inv-7 down toprimary_domainonly and hand subtopic miss-detection to T7/ID-45. Per the S279 correction + Liam’s OQ-63-9 ratification (§0.4), the missing field is the underlying defect to FIX pre-re-ingest, not a reason to defer — so this Task adds the field, persists both dimensions, tightens the columns, and soft-warns on both. The corrected resolution is recorded immediately below; the struck-through original is retained for provenance.
Empirical baseline (unchanged, still accurate): the cocoindex ClassificationExtraction had only content_type, primary_domain, classification_confidence, secondary_classifications, rationale — no primary_subtopic (gitnexus_context §1.3). And flow.py:1115-1123 ci_target.declare_row writes content_items with only id, content_text, embedding, source_document_id, op_id — neither primary_domain nor primary_subtopic is persisted on this path today.
Corrected resolution (RATIFIED — OQ-63-9 / §0.4):
- ADD
primary_subtopic: str | None = NonetoClassificationExtraction(§3.6/§3.7) + aprimary_subtopicline toCLASSIFICATION_PROMPT. The_load_canonical_subtopicsloader is built in this Task (not deferred); subtopic names are globally unique in the snapshot (56 distinct, none under >1domain_id) so v1 validates against a flat_VALID_SUBTOPICSfrozenset — a domain-scoped check is a future enhancement, not required here. - PERSIST both
primary_domainANDprimary_subtopictocontent_itemsinflow.py declare_row(add both keys to the row dict). - TIGHTEN the columns to NOT NULL +
'unclassified'sentinel via one migration (§4.1) — pre-re-ingest, so no NULL backfill is needed on a populated table. - Inv-7 soft-warn covers BOTH
primary_domainANDprimary_subtopic(+secondary_classifications[]). - SURFACE the sentinel (
'unclassified') + out-of-taxonomy rows via the existing/reviewtab + dashboard actionable-insights (NOT a new mechanism), tied to the Inv-7 flow-scope coverage counter (the surfaced count is the operator’s post-re-ingest coverage signal). - Inv-7’s “row written” acceptance still holds: the
model_validatorreturns the instance unchanged; the row write is unaffected (a missing value lands the sentinel) — the soft-warn changes only observability + the persisted sentinel, exactly the Inv-17 graceful shape.
Original (SUPERSEDED — retained for provenance):Inv-7 enforcement scope (v1): the soft-warn coversThis deferral is no longer in force per §0.4.primary_domain(the field that exists), optionallysecondary_classifications[]. Subtopic miss-detection is not implementable on the cocoindex path until aprimary_subtopicfield is added toClassificationExtraction+ the prompt + thecontent_itemswrite — a larger shape/prompt change that belongs to the T7/ID-45 re-ingest spec. The Inv-6_load_canonical_subtopicsloader is documented (§3.6) as a one-line mirror for that future extension.
5.2 12-requirement-type.md is editable_via: admin_ui — the existing parity loop would skip it (Inv-9 mechanism)
Section titled “5.2 12-requirement-type.md is editable_via: admin_ui — the existing parity loop would skip it (Inv-9 mechanism)”The existing markdown-parity.test.ts parity case filters cv.editable_via === 'database_migration' (line 84). 12-requirement-type.md:6 is editable_via: admin_ui (as is 26-form-type.md:6 — which is precisely why form_type parity lives in the separate form-type-parity.test.ts, RESEARCH R6). Therefore Inv-9 cannot be satisfied by adding requirement_type to SNAPSHOT_KEY_BY_CV_NAME alone — the filter excludes it before the lookup. The TECH design (§3.9(ii)) is a standalone parity case not gated on editable_via, mirroring the form-type-parity.test.ts precedent. This directly answers the brief’s flagged verification (“CONFIRM requirement_type’s editable_via does not get it skipped the way form_type is excluded”) — it WOULD be skipped; the standalone guard is the fix.
6. {63.4} PLAN slice outline (NOT authored here — {63.4} owns the records)
Section titled “6. {63.4} PLAN slice outline (NOT authored here — {63.4} owns the records)”{63.4} PLAN is warranted: > 4h, multiple files (Python + TS + markdown), chain-dependent (§4.3), multiple guard kinds. The natural slices (the {63.4} Planner authors the TM-shape Subtask records — this is an outline only):
§0.4 amendment. The original §6 outline below was a 5-slice picture with a single “domain plumbing + soft-warn” S3 and “no DDL”. Per §0.4 (OQ-63-9), the actual authored decomposition (in the
{63.4}PLAN + this reconcile) is 8 slices{63.5}–{63.12}: the old S3 SPLITS into a model-shape+persist slice and a plumbing+soft-warn slice (both extended to subtopic), and TWO new slices are added — the NOT-NULL+sentinel migration and the/review+dashboard surfacing. The table is updated accordingly (ID column = the slice’s{63.N}Subtask).
| Slice | {63.N} | Invariants | Files | Chain | Notes |
|---|---|---|---|---|---|
| S1 — form_type prompt + lock | {63.5} | Inv-1, Inv-2, Inv-3 | prompts.py, test_cocoindex_prompts.py | none (foundation) | Shrink prompt to 8 (3 sites) + rewrite lock test to snapshot-derived. Headline blocker fix. |
| S2 — content_type regression guard | {63.6} | Inv-4 | test_cocoindex_prompts.py | dep [5] | Tighten subset → bidirectional snapshot parity. No prompt edit. |
| S3 — primary_subtopic field + persist both | {63.7} | Inv-5/6/7 (shape) | extraction.py, prompts.py, flow.py, test_cocoindex_extraction.py | none | §0.4: add primary_subtopic to shape + prompt; persist both to content_items. NO DDL here (the migration is {63.11}). MEDIUM-blast. |
| S4 — domain+subtopic plumbing + soft-warn | {63.8} | Inv-5/6/7 | extraction.py, flow_context.py, flow.py, test_cocoindex_extraction.py | dep [7] | §0.4: _VALID_DOMAINS + _VALID_SUBTOPICS; soft-warn on both + secondary; counter in webhook. MEDIUM-blast. |
| S5 — requirement_type snapshot-key + parity | {63.9} | Inv-9 | generate-taxonomy-snapshot.ts, markdown-parity.test.ts | internal §3.9(i)→(ii) | Regenerate snapshot via sync:taxonomy; standalone guard NOT gated on editable_via. |
| S6 — 26-form-type.md prose remediation + guard | {63.10} | Inv-8 | docs/ontology/26-form-type.md, form-type-parity.test.ts | none | Prose table/Definition/Q-OQR1 → 8; Synonyms untouched. |
S7 — content_items NOT-NULL + sentinel migration | {63.11} | Inv-7 (persistence durability) | supabase/migrations/*, database.types.ts (regen) | dep [7] | §0.4/§4.1: tighten primary_domain+primary_subtopic → NOT NULL DEFAULT 'unclassified'. Pre-re-ingest = no NULL backfill. DDL via CLI only. |
S8 — surface sentinel/out-of-taxonomy via /review + dashboard | {63.12} | Inv-7 (coverage surfacing) | app/api/review/queue/route.ts, types/review.ts, components/review/review-tabs.tsx, app/api/insights/route.ts (or app/api/dashboard/route.ts), lib/dashboard.ts | dep [7, 11] | §0.4(e): EXISTING surfaces only; surface primary_domain/primary_subtopic == 'unclassified' OR out-of-taxonomy rows; tie count to the Inv-7 coverage metric. |
Inv-10, Inv-11, Inv-12 are cross-cutting acceptance constraints verified across S1-S8 (no dedicated slice). All Subtask dependencies are sibling-only within ID-63 ({63.6}→[5]; {63.8}→[7]; {63.11}→[7]; {63.12}→[7,11]) — no cross-Task Subtask dependency arises, so the sibling-only constraint (§3.3 / A6) holds and no Task split/merge is needed. Slice count (8) is far under the 25-Subtask soft ceiling.
7. Residual Open Questions (hand-off — not blockers)
Section titled “7. Residual Open Questions (hand-off — not blockers)”- OQ-63-9 —
primary_subtopicon the cocoindex path — RESOLVED-VIA-FIX (S279 + Liam ratification; §0.4).Original recommendation: scope Inv-7’s v1 toSuperseded. Pre-re-ingest is the time to fix the underlying schema: this Task ADDSprimary_domainonly and defer subtopic miss-detection to T7/ID-45.primary_subtopicto the shape + prompt, PERSISTS bothprimary_domain+primary_subtopictocontent_items, TIGHTENS both columns to NOT NULL +'unclassified'sentinel (one migration, §4.1 — pre-re-ingest so no NULL backfill), SOFT-WARNS on both (+secondary_classifications), and SURFACES sentinel/out-of-taxonomy rows via the existing/reviewtab + dashboard actionable-insights tied to the Inv-7 coverage counter. The_load_canonical_subtopicsloader is built now (§3.6), not deferred. (A richer domain-scoped subtopic validity check — is this subtopic valid UNDER this domain viadomain_id— remains a future enhancement; v1 uses the flat globally-unique subtopic set.) No deferral to T7/ID-45 for the field itself. - OQ-63-1a-confirm —
secondary_classificationsin Inv-7 scope? Whether the soft-warn also records misses onsecondary_classifications[](the prompt’s “secondary domain names”) or onlyprimary_domain. Recommended: include it (same loop, cheap). Liam’s call; defaults to include. - Carried from PRODUCT (unchanged):
scripts/kb_pipeline/classify.pyretirement (OQ-63-5/R5) andscripts/ontology-sync/parse-flow.pydisposition (OQ-63-7/R7) remain follow-up dispositions for the Curator, not gated here. ID-149 / ID-173 close-as-resolved routing is Orchestrator→Curator (test-green ≠ integration-correct caveat stands).
All
{63.2}PRODUCT invariantsInv-1 … Inv-12are mapped one-to-one to §3.1 … §3.12 above. The single ratified PRODUCT-MAY-PROPOSE point (Inv-7 enforcement mode) is encoded as soft-warn per S279; the only TECH-gate confirmation outstanding is theprimary_subtopicscoping (OQ-63-9), which does not change any in-scope slice.