Skip to content

ID-63 {63.4} PLAN — Ontology / controlled-vocabulary (CV) integration-correctness gate: Subtask decomposition

ID-63 {63.4} PLAN — Ontology / controlled-vocabulary (CV) integration-correctness gate: Subtask decomposition

Section titled “ID-63 {63.4} PLAN — Ontology / controlled-vocabulary (CV) integration-correctness gate: Subtask decomposition”
  • Task: ID-63 — Ontology / controlled-vocabulary (CV) integration verification across the canonical pipeline.
  • Subtask: {63.4} PLAN (this document). Decomposes the ratified {63.2} PRODUCT (Inv-1 … Inv-12) + {63.3} TECH (§3.1 … §3.12 proposed-changes; §6 slice outline) into TM-shape implementation Subtask records {63.5} … {63.10}.
  • Status: PLAN — authors the Subtask records + dependency ordering. Does NOT implement (that is the Executor per Subtask) and does NOT edit docs/reference/task-list.json (the Orchestrator integrates the records returned in §4).
  • Author: fresh task-planner instance (Q-PLANNER-2 — NOT the author of {63.1} / {63.2} / {63.3}), opus thinking:max, isolated worktree off main @ 4deb29b4.
  • Date: 29/05/2026.
  • 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} — 12 invariants); docs/specs/id-63-ontology-cv-integration/TECH.md ({63.3} — 12 proposed-changes + §6 slice outline).

S279 SCOPE CORRECTION (Liam, post-TECH — OVERRIDES OQ-63-9 / TECH §0.1’s “Inv-7 v1 = primary_domain only / defer subtopic to T7/ID-45”). Pre-re-ingest is the time to FIX the underlying schema, not work around it. The corrected resolution this PLAN authors to: (1) ADD a primary_subtopic field to ClassificationExtraction — the missing field is the underlying defect, not a reason to scope down; (2) PERSIST both primary_domain AND primary_subtopic to content_items in flow.py (today neither is persisted); (3) soft-warn (Inv-7) covers BOTH primary_domain AND primary_subtopic (+ secondary_classifications[]). The {63.3} TECH on main will be amended to match this corrected resolution; this PLAN supersedes OQ-63-9’s deferral. See §1.6 for the migration-coordination finding (the content_items columns already exist — no DDL is needed, materially de-risking Liam’s point 2).

Reading guide. §1 records the code-intelligence orientation (re-run for {63.4}; cited verbatim), including the corrected-scope findings (§1.6). §2 is the dependency graph + sequencing (6 slices). §3 is the per-Subtask plan. §4 is the TM-shape Subtask records for the Orchestrator. §5 is the sibling-only-dependency + 25-ceiling attestation + the ID-64 coordination note. §6 is the checkpoint/risk digest. §7 carries the residual OQ hand-off (OQ-63-9 now RESOLVED-via-fix, not deferred).


1. Code-intelligence orientation (cited verbatim — re-run for {63.4} per Q-PLANNER-2; not inherited)

Section titled “1. Code-intelligence orientation (cited verbatim — re-run for {63.4} per Q-PLANNER-2; not inherited)”

Per the .gitnexus/CLAUDE.md “Always Do” block, orientation was run before authoring this PLAN. TS symbols via gitnexus (repo knowledge-hub); Python / markdown / SQL via grep + python3 (outside the TS index per .ast-dataflow/CLAUDE.md). The orientation confirms every slice-touched site empirically so each Subtask’s details is load-bearing (the Executor needs no spec re-read).

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), proc_28_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. No cocoindex symbol surfaces — confirming the app-side classify path is distinct from the cocoindex re-ingest path the in-scope invariants gate.

1.2 gitnexus_context({name: 'classifyContent', file_path: 'lib/ai/classify.ts'}) — verdict + baseline for Inv-6/7

Section titled “1.2 gitnexus_context({name: 'classifyContent', file_path: 'lib/ai/classify.ts'}) — verdict + baseline for Inv-6/7”

uid Function:lib/ai/classify.ts:classifyContent. Incoming (6 direct callers): scripts/reclassify-items.ts:main, scripts/batch-reclassify-keywords.ts:main, lib/intelligence/pipeline.ts:storeAsContentItem, lib/ingest/markdown-orchestrator.ts:importOneFile, app/api/cron/classification-quality/route.ts:GET, app/api/items/[id]/classify/route.ts:POST. Outgoing includes lib/ai/classify.ts:validateDomain, lib/ai/classify.ts:coerceSubtopic, lib/supabase/safe.ts:sb. processes: []. Verdict: HIGH (broad app/intelligence/cron/MCP fan-out). This is the interactive/UI ingest path — out of scope per PRODUCT §Scope / Inv-11; it is the parity baseline for Inv-6/Inv-7 (the TS path enforces domain via validateDomain’s coerce-to-validDomains[0] fallback — the explicit smell Inv-7 must NOT replicate; and coerceSubtopic — the subtopic analogue). No Subtask in this PLAN edits classify.ts.

1.3 gitnexus_context({name: 'ClassificationExtraction', file_path: 'scripts/cocoindex_pipeline/extraction.py'}) — index miss; resolved by grep

Section titled “1.3 gitnexus_context({name: 'ClassificationExtraction', file_path: 'scripts/cocoindex_pipeline/extraction.py'}) — index miss; resolved by grep”

gitnexus returned "Symbol 'ClassificationExtraction' not found" (the Python cocoindex Pydantic classes are below the indexed surface — .gitnexus/CLAUDE.md reports 36955 symbols vs the root CLAUDE.md’s 45952). Resolved authoritatively by grep (the canonical path for Python per .ast-dataflow/CLAUDE.md): class ClassificationExtraction(_ExtractionBase) at extraction.py:253, fields extraction_kind, content_type, primary_domain, classification_confidence, secondary_classifications, rationaleNO primary_subtopic (the underlying defect the S279 correction FIXES — see §1.6). _validate_content_type field_validator raises ValueError; _PYDANTIC_ERROR_TO_ERROR_CLASS maps "value_error" → "invalid_enum" (lines 295-322). The Inv-7 model_validator(mode="after") returns the instance (no raise) → does NOT interact with this map → the soft-warn does not affect content_type hard-reject (Inv-5 no-regression holds). TECH §1.3’s MEDIUM verdict / 9-importer blast radius (8 test files + flow.py/pair_resolver.py/form_extractors/shared.py) stands at authoring time; the model-shape + soft-warn Subtasks re-run gitnexus_impact before edit per .gitnexus/CLAUDE.md.

1.4 grep sweeps — every slice-touched site confirmed (Python / markdown / SQL, outside the TS index)

Section titled “1.4 grep sweeps — every slice-touched site confirmed (Python / markdown / SQL, outside the TS index)”
  • S1 (prompts.py): Q_A_FORM_PROMPT names 11 form_types at line 104 (FIELD CONSTRAINTS) AND repeats framework / dps / gcloud at line 114 (GUIDANCE); module docstring lines 16-17 asserts “the 11-value form_type Literal”. CLASSIFICATION_PROMPT content_type list at lines 55-56 (15 values); primary_domain free-text-with-examples line at 57; secondary_classifications line at 59NO primary_subtopic line (S3 adds one). Line 81 is a JSON-schema reference (no literal, untouched). Lines 141/153/161 are entity_type (framework legitimately present — different vocabulary; PRODUCT Inv-1 carve-out; do NOT touch).
  • S1 (test_cocoindex_prompts.py): TestPromptsEnumeratesEnums::test_q_a_form_enumerates_form_types at lines 112-130 hardcodes the 11-set with docstring “should list the 11 canonical form_type values” — the GREEN-locked stale contract Inv-3 corrects. test_classification_enumerates_content_types at lines 94-110 is a subset check of 8 “core” values (Inv-4 tightens to bidirectional).
  • S3 (extraction.py ClassificationExtraction): class at line 253, fields enumerated §1.3 — primary_subtopic ABSENT. The discriminated-union root ExtractionOutput (lines ~280-289) references ClassificationExtraction — adding an OPTIONAL field (primary_subtopic: str | None = None) is backward-compatible with the extra='forbid' config (it is a declared field, not extra).
  • S3 (flow.py persistence): extract_classification call at 1076; ci_target.declare_row at 1115-1123 writes content_items with id, content_text, embedding, source_document_id, op_id ONLY — neither primary_domain nor primary_subtopic persisted today (the S279 point-2 defect). The ci_target ColumnDef set is declared where ci_target is mounted (Executor confirms the mount declares primary_domain/primary_subtopic columns, else extends it). The Inv-17 stage-counter bump precedent at 1134-1136 (current_stage_counter().increment("embedding")); app_main at 1411 binds counters (_FlowRetryCounter at 1454); _emit_pipeline_run_webhook at 548.
  • S4 (flow_context.py): ContextVar + Protocol + bind_*/current_* pattern for RetryCounterProtocol (117-145) and StageCounterProtocol (169-198) — the exact mirror for the new TaxonomyMissCounter. Dual-import discipline (importlib.import_module(f"{__package__}…")) used by every existing counter.
  • S5 (generate-taxonomy-snapshot.ts): RPC get_check_constraint_values consumed at lines 105-108 (filters content_type/platform only; reads information_schema server-side per comment 98-99 → may already return requirement_type); hardcoded fallback at 130/133; snapshot object literal at 138-161 (content_types: contentTypes at 155). Confirms TECH §3.9(i).
  • S5 (markdown-parity.test.ts): interface TaxonomySnapshot at lines 30-33 (only content_types?/platforms?); SNAPSHOT_KEY_BY_CV_NAME at 46-48; the editable_via === 'database_migration' filter at line 85. Confirms TECH §5.2 — requirement_type (editable_via: admin_ui) would be SKIPPED, so a standalone case is required.
  • S6 (26-form-type.md): frontmatter baseline_values = 8 (correct); residual prose drift at Definition (line 49), Q-OQR1 note (line 51), ## Baseline values prose table rows 73-75 (framework, dps, gcloud). Synonyms section (53-62, DPS=58 / G-Cloud=60) is acronym aliases — legitimately retained. “Planned 3-tier taxonomy split” (110-128) still says “Status: Planned” (line 112) (optional restamp).
  • S6 (form-type-parity.test.ts): 4 it() cases; reads frontmatter via loadOntologyCVs() (line 65) + snapshot via readFileSync (76).
  • S5 (12-requirement-type.md): frontmatter editable_via: admin_ui (line 6); 7 baseline_values keys; prose table rows 48-54 (also 7).
  • Snapshot inventory (scripts/tests/fixtures/taxonomy_snapshot.json): keys content_types, domains, form_types, generated_at, platforms, subtopics; content_types = 15; form_types = 8; domains = 15 (name e.g. security); subtopics = 56 (name e.g. functionality, with domain_id); no requirement_type key.
  • conftest fixtures (scripts/tests/conftest.py): session-scoped taxonomy_from_snapshot (96-104), valid_domains (108-111 → ["domains"]), valid_subtopics (114-117 → ["subtopics"]) — the Inv-6/Inv-7 tests reuse both ([d["name"] for d in valid_domains] / [s["name"] for s in valid_subtopics]).
  • SQL: form_template_requirements_requirement_type_check = exactly the 7 values (migration 20260416102457:4167, renamed 20260520120828:242) — matches 12-requirement-type.md today; no parity test guards it (Inv-9).

1.5 Empirical pre-ratification verification (OQ-3 / Q-EX2 forcing function)

Section titled “1.5 Empirical pre-ratification verification (OQ-3 / Q-EX2 forcing function)”

This PLAN cites the same external + internal symbols as {63.2} / {63.3}, re-verified against the installed pins in this worktree (off main @ 4deb29b4). No new external-library symbol is introduced by the corrected decomposition.

DatePinSymbol / artefact checkedResult
29/05/2026cocoindex[postgres]==1.0.3extraction.py imports + @coco.fn; ClassificationExtraction body (grep, 253-278)PRESENT (ExtractByLlm known-ABSENT; no primary_subtopic field — the S3 addition target)
29/05/2026pydantic==2.12.5field_validator, model_validator(mode="after"), ConfigDict(extra='forbid') accepts a NEW declared optional field; _PYDANTIC_ERROR_TO_ERROR_CLASS value_error → invalid_enum (295-322)PRESENT — soft-warn model_validator returns instance (no raise); adding primary_subtopic: str | None = None is a declared field (NOT an extra_forbidden violation)
29/05/2026anthropic==0.79.0ANTHROPIC_MODEL = "claude-opus-4-6" (extraction.py:65)PRESENT (unaffected)
29/05/2026zod ^4.4.3 / gray-matter ^4.0.3lib/ontology/schemas.ts:OntologyCVSchema / lib/ontology/loader.ts:loadOntologyCVsPRESENT (S5/S6 TS guards reuse these)

Ground-truth checks (this worktree): Q_A_FORM_PROMPT form_type set = 11; _VALID_FORM_TYPES = 8; {prompt} − {validator} = {dps, framework, gcloud} (SIGNATURE_DRIFT — in-repo defect S1 fixes). CLASSIFICATION_PROMPT content_type set == snapshot content_types exactly (Inv-4 regression guard, no prompt edit). Snapshot has no requirement_type key (Inv-9 adds it). 26-form-type.md prose table lists 11 (Inv-8 remediates to 8). No ABSENT / SIGNATURE_DRIFT against an external pin blocks ratification. PLAN ratification proceeds.

1.6 Corrected-scope findings (S279 — load-bearing for the migration-coordination claim)

Section titled “1.6 Corrected-scope findings (S279 — load-bearing for the migration-coordination claim)”

The S279 correction’s point-2 says the content_items persistence “needs content_items columns — a schema migration … COORDINATED with ID-64”. Empirical grep CORRECTS this premise:

  • content_items.primary_domain and content_items.primary_subtopic columns ALREADY EXISTsupabase/types/database.types.ts content_items Row type (lines 773-774: primary_domain: string | null, primary_subtopic: string | null), Insert/Update variants (851-852, 929-930) also present and already nullable. The columns were added by an earlier migration (the app-side lib/ai/classify.ts path already writes them — it has validateDomain/coerceSubtopic and persists their results). Therefore NO content_items ALTER / DDL is required to satisfy S279 point-2: the persistence slice is a pure flow.py declare_row change (add the two keys to the row dict + confirm the ci_target ColumnDef set declares them). This materially de-risks the slice (no migration, no db push, no prod/staging DDL coordination for the columns themselves). This finding is flagged to the Orchestrator (§5) — the assumed ID-64 column-migration coordination point does NOT arise for these two columns.
  • Subtopic names are GLOBALLY UNIQUE in the snapshot (python3: 56 subtopics, 56 distinct names, zero name appearing under >1 domain_id). Therefore v1 subtopic soft-warn validates primary_subtopic against a FLAT frozenset of subtopic names (_VALID_SUBTOPICS), exactly mirroring _VALID_DOMAINS — no domain_id-scoped (is-this-subtopic-valid-under-this-domain) check is needed for v1. A domain-scoped subtopic check is a richer future enhancement (the snapshot carries domain_id to enable it later) but is NOT required by the corrected Inv-7 soft-warn; the flat-set check is correct and cheap. This is the design basis for _VALID_SUBTOPICS in S4 — recorded for the Executor.

Net effect of the correction on decomposition: the TECH §6 5-slice S3 (“domain plumbing + soft-warn”) SPLITS into two slices — {63.7} (model-shape primary_subtopic field + persist both to content_items, no DDL) and {63.8} (domain+subtopic plumbing + soft-warn covering both + secondary). Total = 6 slices ({63.5}{63.10}), still far under the 25 ceiling. OQ-63-9 moves from DEFERRED to RESOLVED-via-fix (§7).


The TECH §4.3 chain analysis + the S279 correction (§1.6) drive sequencing. Six slices, touching mostly disjoint file-sets (S1↔S2 share test_cocoindex_prompts.py; S3↔S4 share extraction.py + the cocoindex test suite and carry an internal field→validation chain):

{63.5} S1 form_type prompt + lock (prompts.py, test_cocoindex_prompts.py) ── no dep (foundation, headline blocker)
└─►{63.6} S2 content_type regression guard (test_cocoindex_prompts.py) ── dep [5] (shared test file; tighten after S1's lock rewrite)
{63.7} S3 primary_subtopic field + persist both (extraction.py, prompts.py, flow.py, ── no dep (NO DDL — content_items columns already exist, §1.6)
to content_items test_cocoindex_extraction.py)
└─►{63.8} S4 domain+subtopic plumbing + SOFT-WARN (extraction.py, flow_context.py, flow.py, ── dep [7] (soft-warn validates the primary_subtopic field S3 adds; shared extraction.py)
(primary_domain + primary_subtopic test_cocoindex_extraction.py)
+ secondary_classifications[])
{63.9} S5 requirement_type snapshot-key + parity (generate-taxonomy-snapshot.ts, ── no dep (internal chain §3.9(i) snapshot key → §3.9(ii) parity guard)
markdown-parity.test.ts)
{63.10} S6 26-form-type.md prose remediation + guard (26-form-type.md, ── no dep (fix + guard land together)
form-type-parity.test.ts)
  • Two dependency edges, both sibling-only (within ID-63):
    • {63.6} → [{63.5}] — S2 edits the same test file as S1 (test_cocoindex_prompts.py) and logically tightens the content_type guard after S1’s form_type lock rewrite (avoids a merge collision; lets S2 reuse any snapshot-parsing helper S1 extracts).
    • {63.8} → [{63.7}] — S4’s soft-warn model_validator validates self.primary_subtopic, which does not exist until S3 adds the field; and both edit extraction.py ClassificationExtraction. Sequencing them keeps the field-shape change and the validation change as separate, independently-acceptable commits on the same class without collision.
  • {63.5}/S1, {63.7}/S3, {63.9}/S5, {63.10}/S6 are mutually independent → parallelisable in Wave 1. {63.6}/S2 and {63.8}/S4 run in Wave 2 (after their predecessor merges). The Orchestrator MAY dispatch {63.5}, {63.7}, {63.9}, {63.10} concurrently, then {63.6}, {63.8}.
  • No cross-Task Subtask dependency arises (every edge is within ID-63) → sibling-only constraint (§3.3 / A6) HELD; no Task split/merge needed (§5). The ID-64 relationship is a Task-level note (§5), not a Subtask dep — and per §1.6 it does not even require a column migration.
  • Slice count = 6, far under the 25-Subtask soft ceiling.

Risk-ordering: S1 ({63.5}) is the highest-value blocker-grade fix (test-green-but-wrong) and dependency-free → first/earliest. S3+S4 ({63.7}+{63.8}) are the highest-blast-radius slices (MEDIUM, ClassificationExtraction 9 importers) → carry the impact-analysis + full-cocoindex-pytest gate in details.


Each ### {63.N} block maps to a TECH §3.x proposed-change set (as amended by the S279 correction) and PRODUCT Inv-N. details in §4 is the load-bearing dispatch brief; these blocks expand the reasoning. 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 app-side Supabase read; DDL via CLI only) apply to every Subtask per Inv-12 / TECH §3.12.

{63.5} S1 — Shrink Q_A_FORM_PROMPT form_type to the canonical 8 + rewrite the lock test to snapshot-derived (Inv-1, Inv-2, Inv-3 → TECH §3.1, §3.2, §3.3)

Section titled “{63.5} S1 — Shrink Q_A_FORM_PROMPT form_type to the canonical 8 + rewrite the lock test to snapshot-derived (Inv-1, Inv-2, Inv-3 → TECH §3.1, §3.2, §3.3)”

Production change (scripts/cocoindex_pipeline/prompts.py, symbol Q_A_FORM_PROMPT + module docstring), shrink to 8 at three grep-confirmed sites:

  • Line 104 (FIELD CONSTRAINTS): remove framework, dps, gcloudMUST be ONE of: bid, rfp, pqq, itt, tender, checklist, questionnaire, sales_proposal_template.
  • Line 114 (GUIDANCE): Choose bid/rfp/pqq/itt/tender/framework/dps/gcloud for procurement forms;Choose bid/rfp/pqq/itt/tender for procurement forms;
  • Lines 16-17 (docstring): the 11-value form_type Literalthe 8-value form_type set (snapshot-backed; see extraction.py:_VALID_FORM_TYPES); correct the stale “Literal” wording (it is str + field_validator since {52.6}).
  • Do NOT touch: ENTITY_MENTION_PROMPT entity_type list (line 141 — different vocabulary); line 81 (JSON-schema ref, no literal).

Test changes (scripts/tests/test_cocoindex_prompts.py): rewrite TestPromptsEnumeratesEnums::test_q_a_form_enumerates_form_types (lines 112-130) — delete the hardcoded 11-literal; from scripts.cocoindex_pipeline.extraction import _VALID_FORM_TYPES; assert bidirectional (every _VALID_FORM_TYPES member appears in Q_A_FORM_PROMPT AND prompt names none outside it — folds Inv-2). Docstring drops “11 canonical” → “must enumerate exactly the snapshot-backed canonical form_type set (no frozen literal)”. Add Inv-3 drift-tracking sub-assertion (tmp_path snapshot copy + monkeypatch _TAXONOMY_SNAPSHOT_PATH + re-call _load_canonical_form_types → expectation moves). FIELD-CONSTRAINTS parser: re.search(r'form_metadata\.form_type: MUST be ONE of:\s*(.+?)\.', Q_A_FORM_PROMPT, re.S) split on ,.

Acceptance / Verification / Files: see §4 id:5. Scope: S (2 files). Chain: none (foundation).

{63.6} S2 — Tighten the CLASSIFICATION_PROMPT content_type guard to bidirectional snapshot parity (Inv-4 → TECH §3.4)

Section titled “{63.6} S2 — Tighten the CLASSIFICATION_PROMPT content_type guard to bidirectional snapshot parity (Inv-4 → TECH §3.4)”

Test-only (scripts/tests/test_cocoindex_prompts.py, test_classification_enumerates_content_types, lines 94-110). Replace the subset check with an exact bidirectional assertion: parse the content_type set from CLASSIFICATION_PROMPT (lines 55-56), compare against _VALID_CONTENT_TYPES/snapshot['content_types']; assert both directions empty; report both. No prompt edit (already aligned). Dep [5] (sibling, shared test file).

Acceptance / Verification / Files: see §4 id:6. Scope: XS (1 file).

{63.7} S3 — Add primary_subtopic to ClassificationExtraction + the prompt + persist BOTH primary_domain AND primary_subtopic to content_items (S279 correction points 1+2; NO DDL — columns exist)

Section titled “{63.7} S3 — Add primary_subtopic to ClassificationExtraction + the prompt + persist BOTH primary_domain AND primary_subtopic to content_items (S279 correction points 1+2; NO DDL — columns exist)”

S279 SCOPE CORRECTION slice — supersedes TECH §0.1/§5.1 deferral. Highest-blast-radius (MEDIUM, ClassificationExtraction 9 importers). MUST run gitnexus_impact({target: 'ClassificationExtraction', direction: 'upstream', repo: 'knowledge-hub'}) BEFORE editing + gitnexus_detect_changes() BEFORE commit + full cocoindex pytest (per .gitnexus/CLAUDE.md + TECH §4.4).

(a) Model field — scripts/cocoindex_pipeline/extraction.py: add primary_subtopic: str | None = None to ClassificationExtraction (class line 253), positioned beside primary_domain. Optional/nullable so it is backward-compatible with extra='forbid' (a declared field, not extra; pydantic 2.12.5 verified §1.5) and so existing extractions without a subtopic still validate. NO field_validator here (validation is the soft-warn in S4 — keep this slice shape-only).

(b) Prompt — scripts/cocoindex_pipeline/prompts.py CLASSIFICATION_PROMPT: add a primary_subtopic line to the FIELD CONSTRAINTS block (after the primary_domain line 57) and to the JSON shape block (near line 50) — a short snake_case subtopic identifier, nullable, with examples mirroring the primary_domain guidance line’s style. Keep it free-text-with-examples (enforcement is the snapshot-backed soft-warn in S4, NOT a hardcoded prompt list — consistent with Inv-6).

(c) Persistence — scripts/cocoindex_pipeline/flow.py: in ci_target.declare_row (lines 1115-1123) add "primary_domain": classification.primary_domain AND "primary_subtopic": classification.primary_subtopic to the row dict (the classification object is the extract_classification result, line 1076). §1.6: the content_items.primary_domain + primary_subtopic columns ALREADY EXIST (nullable) — NO migration is required. Confirm the ci_target ColumnDef set (where ci_target is mounted in app_main) declares primary_domain/primary_subtopic ColumnDefs; if the mount omits them, ADD the ColumnDefs (still no DDL — the DB columns exist). If the Executor finds (contrary to §1.6) that a column is genuinely absent at the live DB, STOP and escalate to the Orchestrator as the ID-64 coordination point (§5) — do NOT invent a migration in this worktree.

(d) Tests — scripts/tests/test_cocoindex_extraction.py: assert ClassificationExtraction accepts primary_subtopic (present and None); assert an instance round-trips primary_subtopic through TypeAdapter.validate_json. A flow.py write-path test (e.g. test_cocoindex_flow_write_path.py) asserts the declare_row payload now includes primary_domain + primary_subtopic (extend the existing _fake_classification fixture to carry a subtopic).

Acceptance / Verification / Files: see §4 id:7. Scope: M (3 source files + tests, MEDIUM blast). Chain: none (but S4 depends on this).

{63.8} S4 — Domain+subtopic plumbing (_VALID_DOMAINS + _VALID_SUBTOPICS) + SOFT-WARN on out-of-taxonomy primary_domain AND primary_subtopic (+ secondary_classifications[]) (Inv-5/6/7 → TECH §3.5/3.6/3.7, S279-extended to subtopic)

Section titled “{63.8} S4 — Domain+subtopic plumbing (_VALID_DOMAINS + _VALID_SUBTOPICS) + SOFT-WARN on out-of-taxonomy primary_domain AND primary_subtopic (+ secondary_classifications[]) (Inv-5/6/7 → TECH §3.5/3.6/3.7, S279-extended to subtopic)”

Dep [7] (sibling — the soft-warn validates self.primary_subtopic, which S3 adds; both edit ClassificationExtraction). MUST run gitnexus_impact before edit + full cocoindex pytest (same MEDIUM blast as S3).

(a) Inv-6 plumbing — scripts/cocoindex_pipeline/extraction.py: add _load_canonical_domains() -> frozenset[str] AND _load_canonical_subtopics() -> frozenset[str], both mirroring _load_canonical_form_types (verdict LOW): open _TAXONOMY_SNAPSHOT_PATH, read snapshot["domains"] / snapshot["subtopics"], raise ValueError if missing/empty or any entry lacks a string name, return frozenset(row["name"] for row in …). Bind _VALID_DOMAINS + _VALID_SUBTOPICS at module load. §1.6: subtopic names are globally unique → a FLAT subtopic set is correct for v1 (no domain_id-scoping needed; the richer domain-scoped check is a future enhancement). Do NOT hardcode either list in prompts.py/extraction.py (the prompt’s free-text examples stay as guidance; enforcement is the snapshot-backed sets).

(b) Inv-7 soft-warn — scripts/cocoindex_pipeline/extraction.py: add a NON-raising @model_validator(mode="after") _surface_out_of_taxonomy_classification to ClassificationExtraction covering BOTH dimensions (+ secondary): for primary_domain not in _VALID_DOMAINS, AND primary_subtopic (when not None) not in _VALID_SUBTOPICS, AND each secondary_classifications[] value not in _VALID_DOMAINS — lazily importlib.import_module(f"{__package__}.flow_context"), call current_taxonomy_miss_counter() and .record(field=…, value=…) per miss (field ∈ {"primary_domain","primary_subtopic","secondary_classification"}), AND emit a structured logging.getLogger(__name__).warning(...) per miss (“…row written (soft-warn per ID-63 Inv-7)”). Always return self unchanged — no drop, no coercion. Explicitly do NOT replicate the TS validateDomain coerce-to-validDomains[0] fallback NOR coerceSubtopic (the smells PRODUCT Inv-7 names). The model_validator returns the instance → does NOT touch _PYDANTIC_ERROR_TO_ERROR_CLASScontent_type hard-reject (Inv-5) unaffected.

(c) Inv-7 counter — scripts/cocoindex_pipeline/flow_context.py: add a TaxonomyMissCounter dimension mirroring RetryCounterProtocol/StageCounterProtocol (lines 117-198) — ContextVar _taxonomy_miss_counter_var + bind_taxonomy_miss_counter() (token async-CM) + current_taxonomy_miss_counter(), recording {field, value} → count (field distinguishes domain vs subtopic vs secondary). Dual-import discipline.

(d) Inv-7 webhook — scripts/cocoindex_pipeline/flow.py: in app_main (line 1411) bind the taxonomy-miss counter alongside bind_stage_counter/bind_retry_counter; include its tally (broken down by field) in _emit_pipeline_run_webhook (line 548) payload — the coverage metric the operator inspects post-re-ingest. The bump happens inside ClassificationExtraction validation (runs under the flow binding via extract_classification line 1076 — no call-site wiring beyond the app_main bind). With S3, primary_domain/primary_subtopic are now persisted, so the soft-warn surfaces drift on values that LAND in content_items (closing the corpus-wide mis-slug risk RESEARCH §8 names, now for both dimensions).

(e) Inv-5 no-regression witness + Inv-6/7 tests — scripts/tests/test_cocoindex_extraction.py: retain/confirm Inv-5 witness (out-of-vocab content_type "junk"ValidationErrorinvalid_enum). Add: _VALID_DOMAINS == {d['name'] for d in valid_domains} and _VALID_SUBTOPICS == {s['name'] for s in valid_subtopics} (reuse conftest fixtures); an out-of-taxonomy primary_domain OR primary_subtopic validates successfully (instance returned), increments a bound mock counter (correct field), emits the warning (caplog); a valid domain+subtopic → no bump/no warning; an out-of-taxonomy secondary_classifications entry records a secondary_classification miss.

Acceptance / Verification / Files: see §4 id:8. Scope: M (3 source files + tests, MEDIUM blast). Chain: dep [7] (sibling).

{63.9} S5 — Add requirement_type to the snapshot + standalone markdown↔snapshot↔DB-CHECK parity guard NOT gated on editable_via (Inv-9 → TECH §3.9)

Section titled “{63.9} S5 — Add requirement_type to the snapshot + standalone markdown↔snapshot↔DB-CHECK parity guard NOT gated on editable_via (Inv-9 → TECH §3.9)”

Internal chain §3.9(i) → §3.9(ii): (i) scripts/generate-taxonomy-snapshot.ts — extend the RPC pickup (lines 105-108) for a requirement_type row + hardcoded 7-value fallback (['policy','statement','evidence','data','narrative','declaration','reference']); add requirement_type: to the snapshot object (138-161) + a console.log; regenerate the committed fixture via bun run sync:taxonomy after verifying the staging link. (ii) __tests__/lib/ontology/markdown-parity.test.ts — extend interface TaxonomySnapshot (30-33) + add a STANDALONE case (NOT gated on editable_via, per §1.4/TECH §5.2 — 12-requirement-type.md is admin_ui so the line-85 filter skips it) asserting reqCV.baseline_values keys == snapshot.requirement_type both ways (markdown==snapshot==DB CHECK transitively, since the snapshot is DB-derived).

Acceptance / Verification / Files: see §4 id:9. Scope: S (2 source files + regenerated fixture). Chain: internal §3.9(i)→(ii) (no sibling dep).

{63.10} S6 — Remediate 26-form-type.md prose drift to the canonical 8 + prose-table↔frontmatter parity guard (Inv-8 → TECH §3.8)

Section titled “{63.10} S6 — Remediate 26-form-type.md prose drift to the canonical 8 + prose-table↔frontmatter parity guard (Inv-8 → TECH §3.8)”

Fix + guard together. Remediation (docs/ontology/26-form-type.md): delete prose ## Baseline values rows 73-75 (framework/dps/gcloud); reword Definition (line 49) + Q-OQR1 note (line 51) to the 5 procurement form_types; do NOT touch Synonyms (53-62, acronym aliases); optional Status restamp (line 112). Guard (__tests__/lib/ontology/form-type-parity.test.ts, extend): an it() reading 26-form-type.md raw via readFileSync, regex-parsing the ## Baseline values table rows, asserting prose-table key set == frontmatter baseline_values key set (== snapshot 8), both ways.

Acceptance / Verification / Files: see §4 id:10. Scope: S (2 files). Chain: none.

Cross-cutting (no dedicated Subtask — acceptance constraints verified across {63.5}–{63.10})

Section titled “Cross-cutting (no dedicated Subtask — acceptance constraints verified across {63.5}–{63.10})”
  • Inv-10 (TECH §3.10): every new/extended guard lands in scripts/tests/test_cocoindex_prompts.py, scripts/tests/test_cocoindex_extraction.py, scripts/tests/test_cocoindex_flow_write_path.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 existing CI jobs. NONE imports the ID-9 Astro/docubot docs-site sync.
  • Inv-11 (TECH §3.11): scripts/cocoindex_pipeline/extraction.py (driven by flow.py) is the singular canonical re-ingest classifier; no Subtask edits lib/ai/classify.ts (S3+S4 gitnexus_detect_changes() confirm). No second re-ingest classifier referenced.
  • Inv-12 (TECH §3.12): UK English; tests verify behaviour not implementation (load-bearing for S1’s Inv-3 rewrite); no new app-side Supabase read (generate-taxonomy-snapshot.ts is a build-time service-role script, not an app hook); no barrel re-exports; no DDL (the requirement_type CHECK already exists — S5 reads it; the content_items columns already exist — S3 persists into them; soft-warn is application-level). bun run test (not bun test) / python3 -m pytest.

4. TM-shape Subtask records (for the Orchestrator to append to docs/reference/task-list.json)

Section titled “4. TM-shape Subtask records (for the Orchestrator to append to docs/reference/task-list.json)”

Returned here for integration; this PLAN does NOT edit task-list.json (Planner discipline). id is local to parent Task ID-63. status: "pending" initial. dependencies are sibling-only integer arrays. details is the load-bearing dispatch brief — an Executor needs no spec re-read. testStrategy is the one-line acceptance the Checker uses.

[
{
"id": 5,
"title": "Shrink Q_A_FORM_PROMPT form_type to canonical 8 + rewrite lock test to snapshot-derived",
"description": "Remove framework/dps/gcloud from Q_A_FORM_PROMPT's form_type enumeration (3 sites in prompts.py) so the prompt no longer steers the model toward values the validator rejects, and rewrite the lock test to derive its expectation from the snapshot rather than a frozen 11-literal. The headline test-green-but-integration-wrong blocker. Inv-1/2/3 -> TECH 3.1/3.2/3.3.",
"details": "PRODUCT Inv-1/2/3; TECH 3.1/3.2/3.3. FILE scripts/cocoindex_pipeline/prompts.py, symbol Q_A_FORM_PROMPT + module docstring. Shrink to 8 at THREE grep-confirmed sites: (1) line 104 FIELD CONSTRAINTS 'MUST be ONE of: bid, rfp, pqq, itt, tender, framework, dps, gcloud, checklist, questionnaire, sales_proposal_template.' -> remove framework/dps/gcloud -> 'bid, rfp, pqq, itt, tender, checklist, questionnaire, sales_proposal_template.'; (2) line 114 GUIDANCE 'Choose bid/rfp/pqq/itt/tender/framework/dps/gcloud for procurement forms;' -> 'Choose bid/rfp/pqq/itt/tender for procurement forms;'; (3) lines 16-17 docstring 'the 11-value form_type Literal' -> 'the 8-value form_type set (snapshot-backed; see extraction.py:_VALID_FORM_TYPES)' (correct stale 'Literal' — it is str+field_validator since {52.6}). DO NOT touch ENTITY_MENTION_PROMPT entity_type list (line 141 — framework is a different vocabulary) or line 81 (JSON-schema ref). FILE scripts/tests/test_cocoindex_prompts.py: rewrite TestPromptsEnumeratesEnums::test_q_a_form_enumerates_form_types (lines 112-130) — delete hardcoded 11-literal; 'from scripts.cocoindex_pipeline.extraction import _VALID_FORM_TYPES'; assert BIDIRECTIONAL (every _VALID_FORM_TYPES member in Q_A_FORM_PROMPT AND prompt names none outside it — folds Inv-2). Docstring drop '11 canonical' -> 'must enumerate exactly the snapshot-backed canonical form_type set (no frozen literal)'. Add Inv-3 drift-tracking sub-assertion: tmp_path snapshot copy + monkeypatch _TAXONOMY_SNAPSHOT_PATH + re-call _load_canonical_form_types, assert expectation moves. FIELD-CONSTRAINTS parser: re.search(r'form_metadata\\.form_type: MUST be ONE of:\\s*(.+?)\\.', Q_A_FORM_PROMPT, re.S) split on ','. VERIFY: python3 -m pytest scripts/tests/test_cocoindex_prompts.py passes (was 19 passed); framework/dps/gcloud absent from Q_A_FORM_PROMPT form_type sites; gitnexus_detect_changes() scope = prompts.py + the one test file. UK English; tests verify behaviour per docs/reference/test-philosophy.md.",
"status": "pending",
"dependencies": [],
"testStrategy": "python3 -m pytest scripts/tests/test_cocoindex_prompts.py passes; the lock test loads its expected form_type set from taxonomy_snapshot.json (no literal 11), a temp-snapshot mutation moves the expectation, and framework/dps/gcloud are absent from Q_A_FORM_PROMPT's form_type enumeration."
},
{
"id": 6,
"title": "Tighten CLASSIFICATION_PROMPT content_type guard to bidirectional snapshot parity",
"description": "Replace the weak subset content_type check in test_cocoindex_prompts.py with an exact bidirectional snapshot-parity assertion that fails loudly if a content_type drifts between CLASSIFICATION_PROMPT and the snapshot in either direction. Test-only; the prompt is already aligned. Inv-4 -> TECH 3.4.",
"details": "PRODUCT Inv-4; TECH 3.4. TEST-ONLY (no prompt edit — empirically CLASSIFICATION_PROMPT content_type set == snapshot content_types today). FILE scripts/tests/test_cocoindex_prompts.py, symbol TestPromptsEnumeratesEnums::test_classification_enumerates_content_types (lines 94-110). Replace the current subset check (asserts only 8 'core' values present) with: parse content_type set from CLASSIFICATION_PROMPT FIELD-CONSTRAINTS line (lines 55-56, regex 'content_type: MUST be ONE of the following canonical values:\\n (.+?)\\.'), compare against _VALID_CONTENT_TYPES (from scripts.cocoindex_pipeline.extraction) or snapshot['content_types']; assert BOTH prompt-minus-snapshot==empty AND snapshot-minus-prompt==empty; report both directions in the failure message. Regression guard only — fails when a content_type is added to snapshot but not prompt (or vice versa). VERIFY: python3 -m pytest scripts/tests/test_cocoindex_prompts.py::TestPromptsEnumeratesEnums -v passes today; an artificial drift fails it both ways; gitnexus_detect_changes() scope = the one test file. UK English; behaviour-test per test-philosophy.md.",
"status": "pending",
"dependencies": [5],
"testStrategy": "python3 -m pytest scripts/tests/test_cocoindex_prompts.py::TestPromptsEnumeratesEnums passes; the content_type guard asserts CLASSIFICATION_PROMPT's enumerated set == snapshot content_types both ways and reports drift in either direction."
},
{
"id": 7,
"title": "Add primary_subtopic to ClassificationExtraction + prompt + persist primary_domain AND primary_subtopic to content_items (no DDL — columns exist)",
"description": "S279 scope correction (supersedes OQ-63-9 deferral): add the missing primary_subtopic field to the cocoindex ClassificationExtraction shape and the CLASSIFICATION_PROMPT, and persist BOTH primary_domain and primary_subtopic to content_items in flow.py declare_row. The content_items.primary_domain/primary_subtopic columns already exist (nullable) — NO migration required. Shape + persistence only; validation/soft-warn is the dependent slice {63.8}.",
"details": "S279 CORRECTION points 1+2 (supersedes TECH 0.1/5.1 deferral). HIGHEST BLAST — ClassificationExtraction 9 importers (MEDIUM). MUST run gitnexus_impact({target:'ClassificationExtraction',direction:'upstream',repo:'knowledge-hub'}) BEFORE edit + gitnexus_detect_changes() BEFORE commit + full cocoindex pytest. (a) FILE scripts/cocoindex_pipeline/extraction.py: add 'primary_subtopic: str | None = None' to ClassificationExtraction (class at line 253), beside primary_domain. Optional/nullable -> backward-compatible with ConfigDict(extra='forbid') (declared field, NOT extra; pydantic 2.12.5 verified) and existing extractions without a subtopic still validate. NO field_validator here (validation is the {63.8} soft-warn — this slice is shape-only). (b) FILE scripts/cocoindex_pipeline/prompts.py CLASSIFICATION_PROMPT: add a primary_subtopic line to FIELD CONSTRAINTS (after primary_domain line 57) AND to the JSON shape block (near line 50) — short snake_case subtopic identifier, nullable, examples mirroring the primary_domain line's style. Keep free-text-with-examples (enforcement is snapshot-backed soft-warn in {63.8}, NOT a hardcoded prompt list — Inv-6). (c) FILE scripts/cocoindex_pipeline/flow.py: in ci_target.declare_row (lines 1115-1123) add '\"primary_domain\": classification.primary_domain' AND '\"primary_subtopic\": classification.primary_subtopic' to the row dict (classification = extract_classification result, line 1076). content_items.primary_domain + primary_subtopic columns ALREADY EXIST nullable (database.types.ts content_items Row lines 773-774) -> NO migration. Confirm the ci_target ColumnDef set (where ci_target is mounted in app_main) declares primary_domain/primary_subtopic ColumnDefs; if the mount omits them, ADD the ColumnDefs (still no DDL — DB columns exist). IF the Executor finds a column genuinely absent at the live DB (contrary to the type), STOP and escalate to the Orchestrator as the ID-64 coordination point — do NOT invent a migration in this worktree. (d) FILE scripts/tests/test_cocoindex_extraction.py: assert ClassificationExtraction accepts primary_subtopic (present + None) and round-trips it through TypeAdapter.validate_json. FILE scripts/tests/test_cocoindex_flow_write_path.py: extend _fake_classification to carry a subtopic; assert the declare_row payload now includes primary_domain + primary_subtopic. VERIFY: gitnexus_impact reported before edit; python3 -m pytest scripts/tests/test_cocoindex_extraction.py scripts/tests/test_cocoindex_flow_write_path.py + full python3 -m pytest scripts/tests/ green; gitnexus_detect_changes() shows ONLY extraction.py/prompts.py/flow.py/the test files (NO classify.ts per Inv-11). UK English; no DDL.",
"status": "pending",
"dependencies": [],
"testStrategy": "ClassificationExtraction validates with and without primary_subtopic (nullable), the CLASSIFICATION_PROMPT enumerates primary_subtopic, and flow.py declare_row persists both primary_domain and primary_subtopic to content_items (verified by the flow write-path test), with full python3 -m pytest scripts/tests/ passing and no DDL/migration introduced."
},
{
"id": 8,
"title": "Domain+subtopic plumbing (_VALID_DOMAINS + _VALID_SUBTOPICS) + SOFT-WARN on out-of-taxonomy primary_domain AND primary_subtopic (+ secondary_classifications)",
"description": "Add snapshot-backed _VALID_DOMAINS and _VALID_SUBTOPICS to extraction.py and a non-raising model_validator on ClassificationExtraction that writes the row but surfaces a structured warning + flow-scope taxonomy-miss counter for out-of-taxonomy primary_domain, primary_subtopic, and secondary_classifications. No coercion, no hard-reject. S279-extended to cover subtopic. Inv-5/6/7 -> TECH 3.5/3.6/3.7.",
"details": "PRODUCT Inv-5/6/7; TECH 3.5/3.6/3.7 (S279-extended to subtopic — supersedes OQ-63-9 domain-only). DEP [7] (soft-warn validates self.primary_subtopic which {63.7} adds; both edit ClassificationExtraction). HIGHEST BLAST (9 importers) — MUST run gitnexus_impact({target:'ClassificationExtraction',direction:'upstream'}) BEFORE edit + gitnexus_detect_changes() BEFORE commit + full cocoindex pytest. (a) Inv-6 FILE scripts/cocoindex_pipeline/extraction.py: add _load_canonical_domains()->frozenset[str] AND _load_canonical_subtopics()->frozenset[str], both mirroring _load_canonical_form_types (verdict LOW) — open _TAXONOMY_SNAPSHOT_PATH, read snapshot['domains']/['subtopics'], raise ValueError if missing/empty or any entry lacks string 'name', return frozenset(row['name'] for row in ...). Bind module-level _VALID_DOMAINS + _VALID_SUBTOPICS. Subtopic names are GLOBALLY UNIQUE in the snapshot (56 distinct, none under >1 domain_id) -> a FLAT subtopic set is correct for v1 (no domain_id-scoping; domain-scoped check is a future enhancement). DO NOT hardcode either list (prompts.py examples stay as free-text guidance; enforcement from the sets). (b) Inv-7 FILE extraction.py: add @model_validator(mode='after') _surface_out_of_taxonomy_classification to ClassificationExtraction (line 253) covering BOTH dimensions + secondary: for primary_domain not in _VALID_DOMAINS, primary_subtopic (when not None) not in _VALID_SUBTOPICS, each secondary_classifications[] value not in _VALID_DOMAINS — lazily importlib.import_module(f'{__package__}.flow_context'); counter=current_taxonomy_miss_counter(); if counter is not None: counter.record(field=<'primary_domain'|'primary_subtopic'|'secondary_classification'>, value=...); logging.getLogger(__name__).warning('...row written (soft-warn per ID-63 Inv-7)', ...) per miss. ALWAYS return self unchanged (no drop/coerce). DO NOT replicate TS validateDomain coerce-to-validDomains[0] NOR coerceSubtopic (the smells Inv-7 names). model_validator returns instance -> does NOT touch _PYDANTIC_ERROR_TO_ERROR_CLASS (Inv-5 content_type hard-reject unaffected). (c) Inv-7 FILE scripts/cocoindex_pipeline/flow_context.py: add TaxonomyMissCounter mirroring RetryCounterProtocol/StageCounterProtocol (lines 117-198) — ContextVar _taxonomy_miss_counter_var + bind_taxonomy_miss_counter() (token async-CM) + current_taxonomy_miss_counter(), recording {field,value}->count (field distinguishes domain/subtopic/secondary); dual-import discipline importlib.import_module(f'{__package__}...'). (d) Inv-7 FILE scripts/cocoindex_pipeline/flow.py: in app_main (line 1411) bind the taxonomy-miss counter alongside bind_stage_counter/bind_retry_counter; include its tally (broken down by field) in _emit_pipeline_run_webhook (line 548) payload. Bump happens inside ClassificationExtraction validation (runs under flow binding via extract_classification line 1076 — no call-site wiring beyond app_main bind). With {63.7} persisting primary_domain/primary_subtopic, the soft-warn surfaces drift on values that LAND in content_items. (e) Inv-5 witness + Inv-6/7 tests FILE scripts/tests/test_cocoindex_extraction.py: retain/confirm out-of-vocab content_type 'junk' -> ValidationError -> invalid_enum; add _VALID_DOMAINS == {d['name'] for d in valid_domains} AND _VALID_SUBTOPICS == {s['name'] for s in valid_subtopics} (reuse conftest fixtures lines 108-117); an out-of-taxonomy primary_domain OR primary_subtopic validates+returns instance, increments a bound mock counter with correct field, emits warning (caplog); valid domain+subtopic -> no bump/no warning; an out-of-taxonomy secondary_classifications entry records a secondary_classification miss. VERIFY: gitnexus_impact reported; python3 -m pytest scripts/tests/test_cocoindex_extraction.py + full python3 -m pytest scripts/tests/ green; gitnexus_detect_changes() shows ONLY extraction.py/flow_context.py/flow.py/the test file (NO classify.ts). UK English; no DDL (soft-warn app-level).",
"status": "pending",
"dependencies": [7],
"testStrategy": "gitnexus_impact run before edit; an extraction with an out-of-taxonomy primary_domain, primary_subtopic, or secondary_classification validates successfully (instance returned, row written), increments the flow-scope taxonomy-miss counter with the correct field, and emits a structured warning, while content_type hard-reject (invalid_enum) is unaffected and full python3 -m pytest scripts/tests/ passes."
},
{
"id": 9,
"title": "Add requirement_type to taxonomy snapshot + standalone markdown/snapshot/DB-CHECK parity guard (not editable_via-gated)",
"description": "Extend generate-taxonomy-snapshot.ts to emit a requirement_type array (RPC pickup + 7-value fallback) and add a standalone markdown-parity.test.ts case asserting 12-requirement-type.md baseline_values == snapshot requirement_type == live DB CHECK both ways, NOT gated on editable_via (which would skip the admin_ui CV). Closes a latent drift surface before Path-C ships. Inv-9 -> TECH 3.9.",
"details": "PRODUCT Inv-9; TECH 3.9 + 5.2. Chain (i)->(ii). (i) FILE scripts/generate-taxonomy-snapshot.ts: extend get_check_constraint_values RPC consumption (lines 105-108 currently filter content_type/platform) to also pick up requirement_type — 'if (row.column_name === \"requirement_type\") requirementTypes = row.allowed_values;' — with a hardcoded fallback mirror ['policy','statement','evidence','data','narrative','declaration','reference'] when the RPC omits it (mirrors content_type RPC-primary+fallback at 130/133). Add 'requirement_type: requirementTypes' to the snapshot object (lines 138-161) + a console.log count. The RPC reads information_schema server-side (comment 98-99) so it MAY already return requirement_type — Executor verifies via a one-off RPC call and adjusts the fallback; either way the fallback guarantees the key lands. REGENERATE the committed fixture: VERIFY Supabase link FIRST (supabase link --project-ref turayklvaunphgbgscat; cat supabase/.temp/project-ref MUST read turayklvaunphgbgscat — worktree inherits no link state), then bun run sync:taxonomy; the scripts/tests/fixtures/taxonomy_snapshot.json diff (new requirement_type array, 7 values) is part of the deliverable. (ii) FILE __tests__/lib/ontology/markdown-parity.test.ts: extend interface TaxonomySnapshot (lines 30-33) with 'requirement_type?: string[];'; add STANDALONE it('requirement_type markdown baseline_values match the snapshot requirement_type set and the live DB CHECK both ways') NOT gated on editable_via (12-requirement-type.md is editable_via: admin_ui line 6 — the existing databaseMigrationCVs loop at line 85 SKIPS it; SNAPSHOT_KEY_BY_CV_NAME alone is insufficient). Case: 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. Snapshot is DB-derived via (i) -> markdown==snapshot==DB CHECK transitively. Optionally wire requirement_type->'requirement_type' into SNAPSHOT_KEY_BY_CV_NAME for documentation, but the standalone case is the load-bearing guard. (iii) OPTIONAL Python TestRequirementTypeParity in test_cocoindex_extraction.py (snapshot requirement_type non-empty + 7) — NOT required (no Python consumer; Path-C unbuilt); include only if budget allows. VERIFY: post-regen bun run test __tests__/lib/ontology/ + full bun run test (snapshot change may ripple to mcp-fixture-sync.test.ts/doc-freshness.test.ts — update reacting fixtures per CLAUDE.md guard-test gotcha); gitnexus_detect_changes() before commit. UK English; no DDL (CHECK already exists — only read into snapshot).",
"status": "pending",
"dependencies": [],
"testStrategy": "The snapshot exposes a requirement_type set of 7 values; a standalone markdown-parity test (not gated on editable_via) asserts 12-requirement-type.md baseline_values keys == snapshot requirement_type == live DB CHECK both ways and fails on any drift; bun run test __tests__/lib/ontology/ passes."
},
{
"id": 10,
"title": "Remediate 26-form-type.md prose drift to canonical 8 + prose-table/frontmatter parity guard",
"description": "Correct the residual prose drift in 26-form-type.md (Baseline-values table rows + Definition + Q-OQR1 note still listing the legacy 11 with framework/dps/gcloud) to the canonical 8, and add a form-type-parity.test.ts guard asserting the prose table matches the frontmatter baseline_values. Fix and guard land together. Inv-8 -> TECH 3.8.",
"details": "PRODUCT Inv-8; TECH 3.8. Fix + guard together (guard fails until prose fixed). REMEDIATION FILE docs/ontology/26-form-type.md: (1) ## Baseline values prose table — delete rows 73-75 'framework | Framework | core', 'dps | DPS (Dynamic Purchasing System) | core', 'gcloud | G-Cloud | core' -> 8 rows matching frontmatter; (2) ## Definition line 49 'Procurement forms (bid, RFP, PQQ, ITT, tender, framework, DPS, G-Cloud) are CORE...' -> '(bid, RFP, PQQ, ITT, tender) are CORE...' + note framework/DPS/G-Cloud now in sibling CVs procurement_vehicle(30)/procurement_vehicle_instance(31); (3) Q-OQR1 note line 51 'form_type discriminates bid/rfp/pqq/itt/tender/framework/dps/gcloud' -> the 5 procurement form_types (drop the 3 vehicles). DO NOT touch the Synonyms section (lines 53-62 — DPS line 58 / G-Cloud line 60 are legitimate ACRONYM aliases, not form_type values). OPTIONALLY restamp 'Planned 3-tier taxonomy split' Status: Planned (line 112) -> 'shipped (T2, S246/S247)' (NOT load-bearing for the guard). GUARD FILE __tests__/lib/ontology/form-type-parity.test.ts (extend; 4 existing it() cases, reads frontmatter via loadOntologyCVs line 65 + snapshot via readFileSync line 76): add it() that reads 26-form-type.md raw via readFileSync (NOT loadOntologyCVs — frontmatter-only), regex-parses the ## Baseline values table body rows (| key | label | provenance |) under that heading, asserts prose-table key set == frontmatter baseline_values key set (== snapshot 8); report both ways. Closes the unguarded prose surface (RESEARCH R6 / PRODUCT NEW finding). VERIFY: bun run test __tests__/lib/ontology/form-type-parity.test.ts passes; grep -c 'framework|dps|gcloud' docs/ontology/26-form-type.md leaves only Synonyms + 3-tier-split occurrences (prose-table/Definition/Q-OQR1 gone); gitnexus_detect_changes() before commit. UK English.",
"status": "pending",
"dependencies": [],
"testStrategy": "After remediation, frontmatter baseline_values (8) == prose ## Baseline values table (8) == snapshot form_types (8); the new form-type-parity guard fails if the prose table lists a form_type absent from frontmatter (or vice versa); bun run test __tests__/lib/ontology/ passes."
}
]

5. Sibling-only-dependency + 25-ceiling attestation + ID-64 coordination note

Section titled “5. Sibling-only-dependency + 25-ceiling attestation + ID-64 coordination note”
  • Sibling-only dependencies (§3.3 / A6 forcing function) — HELD. Two dependency edges: {63.6} → [{63.5}] and {63.8} → [{63.7}], both within ID-63. No Subtask depends on a Subtask of any other Task. No decomposition pressure surfaced a desire to express a cross-Task Subtask dep. No Task split or merge is required.
  • 25-Subtask soft ceiling (§3.4 / A7) — well clear. 6 implementation Subtasks ({63.5}{63.10}). The S279 correction split the TECH §6 S3 into two slices (model-shape+persistence / plumbing+soft-warn); the count rose 5→6, still far under 25.
  • Cross-cutting Inv-10/11/12 carry no dedicated Subtask — Checker-verified across {63.5}{63.10} (§3 cross-cutting block).
  • ID-64 coordination note (Task-level, NOT a Subtask dep) — and a correction to the brief’s premise. The S279 correction anticipated that persisting primary_domain/primary_subtopic to content_items “needs content_items columns — a schema migration COORDINATED with ID-64”. Empirical finding (§1.6): both columns ALREADY EXIST (nullable) in the live content_items schema (database.types.ts lines 773-774). Therefore no content_items column migration arises for {63.7}, and no ID-64 Task-level dependency is required for these two columns. {63.7}’s details instructs the Executor: if (contrary to the type) a column is genuinely absent at the live DB, STOP and escalate to the Orchestrator (the ID-64 coordination point) rather than invent a migration in the worktree — but the type evidence says this will not trigger. Flagged to the Orchestrator for confirmation (Liam’s point-2 migration assumption appears already-satisfied; if ID-64 nonetheless wants a NOT-NULL tightening or a default on these columns, that is a separate ID-64 decision, not an ID-63 deliverable).

Checkpoint after Wave 1 ({63.5}, {63.7}, {63.9}, {63.10} — parallelisable)

Section titled “Checkpoint after Wave 1 ({63.5}, {63.7}, {63.9}, {63.10} — parallelisable)”
  • python3 -m pytest scripts/tests/ green (S1 prompt+lock; S3 primary_subtopic field + persistence).
  • bun run test __tests__/lib/ontology/ green (S5 requirement_type guard, S6 prose guard).
  • S3 gitnexus_detect_changes() shows NO lib/ai/classify.ts edit (Inv-11) and NO migration file (no DDL — columns exist).
  • S5 snapshot regenerated against the verified staging link (turayklvaunphgbgscat).
  • python3 -m pytest scripts/tests/test_cocoindex_prompts.py green (S2 content_type bidirectional guard on top of S1).
  • python3 -m pytest scripts/tests/ green (S4 domain+subtopic soft-warn on top of S3’s field) — out-of-taxonomy primary_domain/primary_subtopic/secondary_classification all surface via the counter; content_type hard-reject intact.
  • Full bun run test + python3 -m pytest scripts/tests/ regression green.
  • All 12 invariants acceptance-verified (Inv-1…9 by Subtask testStrategy; Inv-10/11/12 cross-cutting); Inv-7 now covers BOTH domain + subtopic (S279).
  • No DDL landed (Inv-12 / §1.6 — content_items columns + requirement_type CHECK already exist).
  • Ready for the Checker pass, then the ID-64 readiness gate-set can record CV-correctness cleared for T7 re-ingest.
RiskImpactMitigation
ClassificationExtraction edits (S3 field + S4 validator) ripple to 8 importing test filesMEDIUMgitnexus_impact before each edit + full python3 -m pytest scripts/tests/ before commit (baked into {63.7}+{63.8}); S4 depends on [7] so the field lands first.
content_items column assumed-present is actually absent at live DBMEDIUM (would block S3 persistence){63.7} details: type evidence (database.types.ts 773-774) says present; if absent, STOP + escalate to Orchestrator (ID-64 coordination) — do NOT invent a migration. §5.
S5 snapshot regen lands on prod (worktree link drift)HIGH (wrong-DB)Verify-and-relink to turayklvaunphgbgscat FIRST + cat supabase/.temp/project-ref (baked into {63.9}, CLAUDE.md gotcha).
Snapshot change (S5) breaks mcp-fixture-sync.test.ts / doc-freshness.test.tsLOWFull bun run test post-regen + update reacting fixtures (baked into {63.9}).
S1↔S2 / S3↔S4 merge collision on a shared fileLOW{63.6} dep [5], {63.8} dep [7] → sequenced into Wave 2, not concurrent with their predecessor.
Inv-7 mode re-opened (soft-warn → hard-reject)LOW (ratified)S279 RATIFIED soft-warn; {63.8} encodes soft-warn for both dimensions; a flip would invert to raising field_validators + new error classes — surface as an OQ, do NOT pre-build.

  • OQ-63-9 — primary_subtopic on the cocoindex path — RESOLVED-VIA-FIX (S279 correction; was DEFERRED in TECH §0.1/§5.1/§7). The missing primary_subtopic field is now treated as the underlying defect to FIX pre-re-ingest, not a reason to scope down: {63.7} adds the field + prompt line + persists both dimensions to content_items (no DDL — §1.6); {63.8} soft-warns on both. No deferral to T7/ID-45 for the field itself. (A richer domain-scoped subtopic validity check — is this subtopic valid UNDER this domain, using the snapshot’s domain_id — remains a future enhancement; v1 uses the flat globally-unique subtopic set per §1.6.)
  • OQ-63-1a-confirm — secondary_classifications in Inv-7 scope? RESOLVED: include (per the {63.4} dispatch brief — same loop, cheap). Baked into {63.8}/(b).
  • NEW (this PLAN) — ID-64 content_items column-migration premise. Liam’s S279 point-2 assumed a content_items migration coordinated with ID-64; §1.6 finds the columns already exist (nullable). Surfaced to the Orchestrator (§5): confirm no ID-64 column-migration is needed for primary_domain/primary_subtopic; if ID-64 wants a NOT-NULL/default tightening, that is a separate ID-64 decision, not an ID-63 deliverable.
  • Carried (unchanged, Curator/Orchestrator dispositions — NOT gated here): scripts/kb_pipeline/classify.py retirement (OQ-63-5/R5); scripts/ontology-sync/parse-flow.py disposition (OQ-63-7/R7); ID-149 / ID-173 close-as-resolved routing (Orchestrator→Curator; test-green ≠ integration-correct caveat stands).

All {63.2} PRODUCT invariants Inv-1 … Inv-12 are covered: Inv-1/2/3 → {63.5}; Inv-4 → {63.6}; Inv-5/6/7 → {63.7}+{63.8} (Inv-7 S279-extended to domain+subtopic+secondary); Inv-9 → {63.9}; Inv-8 → {63.10}; Inv-10/11/12 → cross-cutting (§3). The 6 TM-shape records in §4 are ready for the Orchestrator to append to docs/reference/task-list.json under ID-63.