Skip to content

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 invariants Inv-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 off main.
  • 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_domain only / 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 no primary_subtopic; the editable_via filter excludes requirement_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)”
  1. 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-taxonomy primary_domain value 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 TS validateDomain coerce-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).
  2. OQ-63-1a = SHRINK to 8. framework / dps / gcloud were migrated out of form_type by the T2 combined-PR migration (20260520120828_t2_combined_pr_intel_shape_b_form_type_split.sql) into procurement_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).
  3. 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-side lib/ai/classify.ts path is unmodified (referenced only as the Inv-6/Inv-7 parity baseline). scripts/kb_pipeline/classify.py retirement and scripts/ontology-sync/parse-flow.py disposition are follow-up dispositions, not deliverables (§7).
  4. 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_subtopic to ClassificationExtraction + the CLASSIFICATION_PROMPT — the missing field is the underlying defect, not a reason to scope down; (b) PERSIST both primary_domain AND primary_subtopic to content_items in flow.py declare_row (today neither is persisted on this path); (c) TIGHTEN content_items.primary_domain + primary_subtopic to 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 BOTH primary_domain AND primary_subtopic (+ secondary_classifications[]); (e) surface sentinel ('unclassified') + out-of-taxonomy rows via the EXISTING /review tab + 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_json
Method:scripts/tests/test_cocoindex_extraction.py:TestFormTypeParity.test_loaded_set_matches_snapshot_json
Function: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, rationaleNO 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.pyQ_A_FORM_PROMPT names 11 form_types at line 104 (FIELD CONSTRAINTS) AND repeats framework / dps / gcloud at line 114 (GUIDANCE). The module docstring lines 16-17 also asserts “11-value form_type Literal”. CLASSIFICATION_PROMPT content_type list at lines 55-56.
  • scripts/cocoindex_pipeline/flow.py:1076classification = await extract_classification(content_text); ci_target.declare_row at 1115-1123 writes content_items with only id, content_text, embedding, source_document_id, op_idprimary_domain is NOT persisted to content_items today (§5.1). Flow-scope counters use flow_context_module.current_stage_counter() (line 1134) — the Inv-7 surface attaches here.
  • docs/ontology/26-form-type.md — frontmatter baseline_values = 8 (correct, lines 10-42); residual prose drift at Definition (line 49), Q-OQR1 note (line 51), and ## Baseline values prose 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 — frontmatter baseline_values = 7; editable_via: admin_ui (line 6 — material for Inv-9, §5.2).
  • __tests__/lib/ontology/markdown-parity.test.tsSNAPSHOT_KEY_BY_CV_NAME (lines 46-49) maps only content_type/platform; the parity loop filters cv.editable_via === 'database_migration' (line 84); the TaxonomySnapshot TS interface (lines 30-33) declares only content_types?/platforms?.
  • SQL: form_template_requirements_requirement_type_check enforces exactly ('policy','statement','evidence','data','narrative','declaration','reference') (migration 20260416102457:4167, renamed 20260520120828: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.

DatePinSymbol / artefact checkedResult
29/05/2026cocoindex[postgres]==1.0.3scripts/cocoindex_pipeline/extraction.py imports cocoindex as coco, @coco.fn(memo=True); module imports cleanlyPRESENT (ExtractByLlm known-ABSENT and avoided per {52.1})
29/05/2026pydantic==2.12.5field_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/2026anthropic==0.79.0ANTHROPIC_MODEL = "claude-opus-4-6" (extraction.py:65)PRESENT (unaffected by this spec)
29/05/2026gray-matter ^4.0.3lib/ontology/loader.ts:loadOntologyCVs frontmatter parsePRESENT
29/05/2026zod ^4.4.3lib/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 keys content_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 (name e.g. security); subtopics = list of {id, domain_id, name, display_order, provenance, description} objects (name e.g. functionality, domain-scoped via domain_id); no requirement_type / requirement_types key (grep -c → 0). Confirms Inv-6 reads domains[].name / subtopics[].name; Inv-9 must ADD the snapshot key.
  • Inv-1/Inv-2 form_type byte-diff (empirical python3 import-and-compare): Q_A_FORM_PROMPT form_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 exactly dps/framework/gcloud; after the fix {prompt} ⊆ _VALID_FORM_TYPES holds. SIGNATURE_DRIFT confirmed (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_PROMPT content_type set == snapshot content_types exactly (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.py19 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:72 reads frontmatter baseline_values only (NOT prose) → the prose-table drift is genuinely unguarded.
  • Inv-9 editable_via: 12-requirement-type.md:6 = editable_via: admin_ui (NOT database_migration). The existing parity loop (markdown-parity.test.ts:84) would skip it. → Inv-9 needs a guard that does NOT gate on editable_via (§3.9, §5.2).
  • Inv-9 live CHECK: form_template_requirements_requirement_type_check = exactly the 7 values, matching 12-requirement-type.md frontmatter (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’s testStrategy will carry. KH quality bars (UK English; bun run test / python3 -m pytest; tests verify behaviour per docs/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):

  1. 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 list bid, rfp, pqq, itt, tender, checklist, questionnaire, sales_proposal_template. — remove framework, dps, gcloud.
  2. 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.
  3. Lines 16-17 (module docstring): change “the 11-value form_type Literal” to “the 8-value form_type set (snapshot-backed; see extraction.py:_VALID_FORM_TYPES)”. (Also note form_type is no longer a Literal — it is a str + field_validator per {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 in Q_A_FORM_PROMPT AND 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 (the taxonomy_from_snapshot conftest fixture already does this) and build {r['key'] for r in snapshot['form_types']}.
  • 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_path snapshot with an added/removed form_type, reload, assert the expectation moves) — proving the expectation is data-derived, not literal. (Mechanism: parametrise the loader path, or monkeypatch _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 subtopics are 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() (reading snapshot["subtopics"][].name → flat frozenset(_VALID_SUBTOPICS); subtopic names are globally unique, §5.1). Both _VALID_DOMAINS and _VALID_SUBTOPICS are 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_domain only / defer subtopic” to cover BOTH primary_domain AND primary_subtopic (+ secondary_classifications[]), with both fields PERSISTED to content_items (a primary_subtopic field is added to ClassificationExtraction + the prompt; both persisted in flow.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 /review tab + dashboard actionable-insights tied to this counter. The _load_canonical_subtopics loader 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 self

Optionally 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_validator on primary_domain (mapped to a new invalid_domain error 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 values prose table (lines 66-78): delete the three rows framework | 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 CVs procurement_vehicle (30) / procurement_vehicle_instance (31).
  • Q-OQR1 note (line 51): reword “form_type discriminates bid / 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_values RPC scans all CHECK constraints (it reads information_schema server-side per the generator comment line 98-99), the requirement_type row 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) with requirement_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_via filter)”.

(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-js service-role client; no sb()/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/loader directly (existing pattern).
  • Exactly one DDL migration (OQ-63-9 / §4.1) — the content_items.primary_domain + primary_subtopic NOT-NULL + 'unclassified' sentinel tightening, via supabase migration new + db push (link-verify first). The live requirement_type CHECK already exists (§3.9 only reads it). No PL/pgSQL function is added (the SET search_path gotcha 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 commandsbun run test (not bun 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.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. The content_type / form_types sources are unchanged.
  • OQ-63-9 (Inv-7 persistence) requires ONE migrationsupabase migration new oq_63_9_content_items_classification_not_null (then supabase migration up on the preview branch; db push after link-verify per the CLAUDE.md gotcha): tighten content_items.primary_domain and content_items.primary_subtopic from nullable to NOT 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 NULL needs no NULL backfill on a populated table. (If the Executor finds the target branch is NOT clean — i.e. existing rows have NULL primary_domain/primary_subtopic — STOP and escalate: a UPDATE ... SET primary_domain = 'unclassified' WHERE primary_domain IS NULL backfill step must precede SET NOT NULL, and the pre-re-ingest assumption is violated.)
    • DDL via CLI only (supabase migration new + db push), never MCP execute_sql; no PL/pgSQL function is added so the SET search_path gotcha does not apply here.
  • 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 action
cat supabase/.temp/project-ref # MUST read turayklvaunphgbgscat
bun 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) + full python3 -m pytest scripts/tests/ regression (the ClassificationExtraction change is MEDIUM-blast across 8 importing test files per §1.3).
  • TS: bun run test __tests__/lib/ontology/ (Inv-8, Inv-9) + bun run test full 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 and gitnexus_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_subtopic field as a reason to scope Inv-7 down to primary_domain only 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, rationaleno 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_idneither primary_domain nor primary_subtopic is persisted on this path today.

Corrected resolution (RATIFIED — OQ-63-9 / §0.4):

  • ADD primary_subtopic: str | None = None to ClassificationExtraction (§3.6/§3.7) + a primary_subtopic line to CLASSIFICATION_PROMPT. The _load_canonical_subtopics loader is built in this Task (not deferred); subtopic names are globally unique in the snapshot (56 distinct, none under >1 domain_id) so v1 validates against a flat _VALID_SUBTOPICS frozenset — a domain-scoped check is a future enhancement, not required here.
  • PERSIST both primary_domain AND primary_subtopic to content_items in flow.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_domain AND primary_subtopic (+ secondary_classifications[]).
  • SURFACE the sentinel ('unclassified') + out-of-taxonomy rows via the existing /review tab + 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_validator returns 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 covers primary_domain (the field that exists), optionally secondary_classifications[]. Subtopic miss-detection is not implementable on the cocoindex path until a primary_subtopic field is added to ClassificationExtraction + the prompt + the content_items write — a larger shape/prompt change that belongs to the T7/ID-45 re-ingest spec. The Inv-6 _load_canonical_subtopics loader is documented (§3.6) as a one-line mirror for that future extension. This deferral is no longer in force per §0.4.

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}InvariantsFilesChainNotes
S1 — form_type prompt + lock{63.5}Inv-1, Inv-2, Inv-3prompts.py, test_cocoindex_prompts.pynone (foundation)Shrink prompt to 8 (3 sites) + rewrite lock test to snapshot-derived. Headline blocker fix.
S2 — content_type regression guard{63.6}Inv-4test_cocoindex_prompts.pydep [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.pynone§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/7extraction.py, flow_context.py, flow.py, test_cocoindex_extraction.pydep [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-9generate-taxonomy-snapshot.ts, markdown-parity.test.tsinternal §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-8docs/ontology/26-form-type.md, form-type-parity.test.tsnoneProse 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.tsdep [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_subtopic on the cocoindex path — RESOLVED-VIA-FIX (S279 + Liam ratification; §0.4). Original recommendation: scope Inv-7’s v1 to primary_domain only and defer subtopic miss-detection to T7/ID-45. Superseded. Pre-re-ingest is the time to fix the underlying schema: this Task ADDS primary_subtopic to the shape + prompt, PERSISTS both primary_domain + primary_subtopic to content_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 /review tab + dashboard actionable-insights tied to the Inv-7 coverage counter. The _load_canonical_subtopics loader is built now (§3.6), not deferred. (A richer domain-scoped subtopic validity check — is this subtopic valid UNDER this domain via domain_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_classifications in Inv-7 scope? Whether the soft-warn also records misses on secondary_classifications[] (the prompt’s “secondary domain names”) or only primary_domain. Recommended: include it (same loop, cheap). Liam’s call; defaults to include.
  • Carried from PRODUCT (unchanged): scripts/kb_pipeline/classify.py retirement (OQ-63-5/R5) and scripts/ontology-sync/parse-flow.py disposition (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 invariants Inv-1 … Inv-12 are 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 the primary_subtopic scoping (OQ-63-9), which does not change any in-scope slice.