WP6 — Markdown Ontology Harness (cocoindex parse stub + Zod validation + Vitest parity + downstream consumer wire-up)
WP6 — Markdown Ontology Harness (cocoindex parse stub + Zod validation + Vitest parity + downstream consumer wire-up)
Section titled “WP6 — Markdown Ontology Harness (cocoindex parse stub + Zod validation + Vitest parity + downstream consumer wire-up)”Status: Ready for S236 Wave D Implementer (B2 verifier findings addressed via C1 edits). Scope: the validation-and-consumer slice of WP6. The 29 markdown files themselves are produced by the WP6 Drafter / Verifier / Editor sub-agents in earlier waves; this spec covers the code that ratifies them and wires the first downstream consumer. (ID-133 amendment: the register has since grown to 37 CVs — see §3 below.) PRODUCT.md: none — pure-infrastructure work. The behaviour this spec ships is “ontology-driven guarantees”: the markdown register and the live database CHECK constraint cannot drift apart without a test failing.
1. Goals
Section titled “1. Goals”- Make the markdown ontology register under
docs/ontology/*.mdthe single source-of-truth artefact for KH controlled vocabularies, by wrapping it in a Zod-validated loader plus a parity test that fails the build when markdown and DB CHECK constraints disagree. - Wire the first downstream consumer (
content_items.content_type) so the existing 15-value hard-coded list inlib/validation/schemas.tsstops being authored by hand and instead derives from the markdown ontology — proving the loop end-to-end. - Land a cocoindex flow stub at
scripts/ontology-sync/parse-flow.pyso the architectural placeholder for a future automated sync exists in the tree, follows the layered fn-shape ratified by the WP7 S9 spike, and is shaped for the build-out in a later session.
2. Non-goals
Section titled “2. Non-goals”- NOT building GitBook integration. The README documents the future plan only.
- NOT replacing or extending the existing taxonomy admin UI (
contexts/taxonomy-context.tsx, settings-page sections). v1.1 admin UI is out of scope per Q-OQR1-13. - NOT migrating any of the other 14 hard-coded enums in
lib/validation/schemas.tsto be markdown-driven this session. OnlyVALID_CONTENT_TYPESswitches; the rest remain hand-authored until later WP6 waves. - NOT running the cocoindex flow in CI or in dev. The Python file is a typed architectural stub — no engine boot, no DB writes.
- NOT touching the multi-agent authorship workflow. Drafter / Verifier / Editor / Ratifier sequencing is owned by the broader WP6 plan; this spec is the code the Ratifier (and CI) leans on.
3. Architecture overview
Section titled “3. Architecture overview”ID-133 amendment (BI-9). This diagram and the “Data flow” prose below it describe the S238-era architecture: the register living at the public repo’s
docs/ontology/*.md, read at runtime bylib/ontology/loader.ts. Both premises are superseded. Since ID-68.27 the register lives in this private docs-site repo atsrc/content/docs/ontology/*.md— the public repo retains only a frozen fixture. Since ID-133 Decision A, the register is 37 CVs (the original 33 +entity_type,relationship,three_layer_model,concept_type), the register markdown is never read at runtime, andlib/ontology/loader.tsis retired (see §5.2 below). The live authority chain is: private register (human source of truth) → public fixture__tests__/fixtures/ontology/ontology-cv-baselines.json→ DB-derivedscripts/tests/fixtures/taxonomy_snapshot.json→ build-timelib/ontology/content-type-values.generated.ts— see the register’s ownREADME.md§“Decision-A authority chain” for the full table. The diagram below is retained as the historical record of the original WP6 build; do not use it to navigate the current architecture.
docs/ontology/*.md (29 CV files, frontmatter per WP-ONTO-R1 §6.3) │ ├──────────────────────────────┐ │ │ │ Build-time path │ Server / test path │ (predev + prebuild) │ (Node + Vitest) ▼ ▼scripts/generate-content-type- lib/ontology/loader.ts values.ts (codegen) (sync fs.readdirSync at module load) │ │ │ writes │ parses with gray-matter, validates per file ▼ ▼lib/ontology/content-type- lib/ontology/schemas.ts (OntologyCVSchema, Zod) values.generated.ts │ - frozen tuple of 15 keys ▼ - NO node:fs at runtime __tests__/lib/ontology/markdown-parity.test.ts - client-bundle-safe - every .md parses + validates │ - DB CHECK ↔ baseline_values bidirectional set equality │ re-exported by - content_type 15-value spot-check (asserts the ▼ generated tuple + the loader + the DB snapshot agree)lib/ontology/content-type-registry.ts - thin re-export shim - retained for the consumer-import contract │ ▼lib/validation/schemas.ts: VALID_CONTENT_TYPES ─▶ lib/validation/ingest-schemas.ts (re-exports CONTENT_TYPE_VALUES from registry; no longer hand-edited)
scripts/ontology-sync/parse-flow.py ── architectural stub ──▶ (NOT executed) layered fn-shape per WP7 S9 spike (inner fns take `content_text: str`, not FileLike)Data flow at build time: bun run generate:content-type-values (wired as predev + prebuild in package.json) executes scripts/generate-content-type-values.ts, which reads docs/ontology/04-content-type.md frontmatter with gray-matter and writes the static lib/ontology/content-type-values.generated.ts tuple. lib/ontology/content-type-registry.ts re-exports CONTENT_TYPE_VALUES from the generated file — so the consumer import path (@/lib/ontology/content-type-registry) contains no node:fs reads and is safe for client chunks.
Data flow at runtime (server / test only) — S238-era, retired (ID-133). loadOntologyCVs() ran once per Node process at first import. This paragraph describes the pre-ID-133 architecture: lib/ontology/loader.ts is now retired (§5.2) and the register markdown is never read at runtime by anything — see the §3 amendment above and the register’s own README.md “Decision-A authority chain”.
Data flow in CI: Vitest runs markdown-parity.test.ts, which asserts the Decision-A authority chain agrees (public fixture ↔ generated tuple ↔ taxonomy_snapshot.json) — see §5.4. If any artefact drifts from another, the test fails with a list of missing keys. (Pre-ID-133, this test imported the now-retired loader directly against the live markdown; post-ID-133 the markdown lives in the private docs-site and is compared via the public fixture instead.)
4. Context — current state
Section titled “4. Context — current state”| File | Lines | Why it matters |
|---|---|---|
lib/validation/schemas.ts | 41-57 | Defines VALID_CONTENT_TYPES (15 values) — this is the existing hard-coded list the registry replaces. |
lib/taxonomy/taxonomy.ts | 15-18 | 24-line shim re-exporting VALID_CONTENT_TYPES as CONTENT_TYPES. Continues to work after the swap (re-exports are unchanged). |
__tests__/validation/schema-db-consistency.test.ts | 1-90 | Prior-art: existing test that already compares VALID_CONTENT_TYPES against taxonomy_snapshot.json. The new parity test extends the same pattern to the markdown register. |
scripts/tests/fixtures/taxonomy_snapshot.json | top-level content_types[] | Live-DB snapshot of the 15 CHECK values, refreshed by bun run sync:taxonomy. The parity test uses this as the DB-side oracle. |
scripts/generate-taxonomy-snapshot.ts | 76-114 | Introspects information_schema for the CHECK constraint; produces the snapshot above. No change required. |
supabase/types/database.types.ts | 624, 701, 778, 868… | content_type: string everywhere — Constants.public.Enums is empty (CHECK, not ENUM type). The snapshot file is the authoritative TS-visible list. |
docs/plans/phase-0-investigation/phase-b-prerequisite-1-onthology-pipeline.md | §2.1, §6 Phase 1 | Source-of-truth for the 29 CVs and the build sequence. |
docs/plans/phase-0-investigation/phase-b-prerequisite-1-onthology-pipeline-feedback-investigation.md | §6.3 | Source-of-truth for the YAML frontmatter shape. The Zod schema below is its contract. |
docs/plans/phase-0-investigation/0.9-spike-S9-cocoindex-idempotency.md | §7.2 | Source-of-truth for the layered cocoindex fn-shape. Inner fns must take content_text: str not FileLike. |
5. File-by-file plan
Section titled “5. File-by-file plan”5.1 lib/ontology/schemas.ts (NEW)
Section titled “5.1 lib/ontology/schemas.ts (NEW)”Purpose: Zod contract for every docs/ontology/*.md file’s YAML frontmatter. Mirrors lib/validation/schemas.ts patterns: as const literal tuples, z.enum(...), no z.any().
Exports:
OntologyCVSchema— Zod schema for one CV’s frontmatter (.strict()— unknown fields fail loudly).OntologyCV— inferred TypeScript type (z.infer<typeof OntologyCVSchema>).PROVENANCE_VALUES—['core', 'client', 'recommended'] as const.EDITABLE_VIA_VALUES—['database_migration', 'admin_ui', 'seed_data'] as const.PROVENANCE_MODEL_VALUES—['core', 'client', 'hybrid'] as const. (Aligned to A1 + README §6.2 — wasclient_definedin the A2 draft; A1 shipsclient.)STATUS_VALUES—['active', 'planned', 'needed'] as const. Perdocs/ontology/README.mdlifecycle classification; every A1 file declaresstatus:.LAYER_VALUES—[1, 2, 3, 4, 5, 6] as const.
Dependencies: zod only (already a project dep).
Zod shape (the contract — see §6 below for the full code).
5.2 lib/ontology/loader.ts (NEW) — retired (ID-133)
Section titled “5.2 lib/ontology/loader.ts (NEW) — retired (ID-133)”ID-133 amendment.
lib/ontology/loader.tsis retired per Decision A (specs/id-133-ontology-three-layer-pass/TECH.md§“Decision A”; see the register’s ownREADME.md“Decision-A authority chain”). The register markdown now lives in this private docs-site repo, not the public repo’sdocs/ontology/, and is never read at runtime — the human contract is the markdown itself; machines read the public fixture / DB snapshot / generated tuple instead. The section below is retained as the historical record of the original S236-era design; do not re-point a live consumer at this file.
Purpose (S236-era, no longer built): synchronous reader for docs/ontology/*.md. One pass at module load; validates every file; throws fatally on parse error or schema violation with the offending file path.
Exports:
loadOntologyCVs(): readonly OntologyCV[]— returns the cached parsed+validated array. First call reads from disk; subsequent calls return the cached result.ONTOLOGY_DIR—path.join(repoRoot, 'docs/ontology')constant for callers that want to reference the source dir (test reporters, etc.).
Dependencies:
node:fs(readdirSync,readFileSync) — sync I/O is correct here; this runs once per Node process at import time.node:path.gray-matter— NEW dep. Add topackage.jsondependencies(bun add gray-matter). Justification: standard ecosystem choice for YAML frontmatter; tiny (~7 KB); used bynext/mdxand similar. Alternatives considered: hand-rolled regex (brittle),js-yamldirect (no body separation). gray-matter is the right tool.lib/ontology/schemas.ts.
Behaviour:
readdirSync(ONTOLOGY_DIR)filtered to*.mdexcludingREADME.md.- For each file:
readFileSync(file, 'utf8')→matter(content)→OntologyCVSchema.parse(matter.data). - Validation failure throws an
Errorwhose message includes the file path and the Zod issue list. - Cache result in module-scope
let cached: readonly OntologyCV[] | nullso subsequent imports reuse it. The cached array is the SAME reference across all callers — do not mutate. Thereadonlytypes signal intent; TypeScript does not enforce immutability at runtime, so a one-line comment inloader.tsreminds future maintainers. - Files are sorted by filename (so the array is deterministic for snapshot-style assertions).
Why synchronous: Node ESM allows top-level await, but this loader runs in test context (Vitest) and as a build-time data source for the content-type-registry. Sync readFileSync keeps the consumer API plain (const types = CONTENT_TYPE_VALUES) instead of forcing async at every call site.
5.3 Content-type registry — build-time codegen architecture
Section titled “5.3 Content-type registry — build-time codegen architecture”S238 amendment. This section was rewritten during S238 WP3 follow-up to reflect the codegen architecture that S237 commit
13f30993(fix(ci): unblock main build — node:fs leak in client bundle + Supabase typed Update propagation) shipped. The original D1 implementer wired the registry as a direct, loader-driven runtime export; the Turbopack failure described below forced the split into a build-time-generated file plus a thin re-export shim. The earlier shape is preserved in the git history of this spec. Closes E2 implementation-ratifier Check 6 (PASS-WITH-NOTES).
Production path overview:
| File | Role | Runtime cost |
|---|---|---|
scripts/generate-content-type-values.ts | Codegen script — reads docs/ontology/04-content-type.md frontmatter via gray-matter and writes a static readonly string[] tuple of baseline_values[].key. | Build only. Not imported at runtime. |
lib/ontology/content-type-values.generated.ts | Auto-generated re-export of CONTENT_TYPE_VALUES as a Object.freeze([...] as const) tuple. No node:fs, no gray-matter, no top-level side effects. | Inlined into both server and client bundles. |
lib/ontology/content-type-registry.ts | One-line re-export shim: export { CONTENT_TYPE_VALUES } from '@/lib/ontology/content-type-values.generated';. Preserves the consumer-import contract documented elsewhere in this spec. | Trivial. |
lib/ontology/loader.ts | Retired (ID-133) — see §5.2. S238-era row, no longer built: was retained for the parity test and any future server-only consumer that needed the full register. | N/A — retired. |
5.3.1 Why a codegen step (rationale)
Section titled “5.3.1 Why a codegen step (rationale)”Next.js 16’s Turbopack rejects external node:fs requests in client chunks:
the chunking context (unknown) does not support external modules (request: node:fs)
The original WP6 D1 implementation (lib/ontology/content-type-registry.ts imports loadOntologyCVs() from lib/ontology/loader.ts, which calls readdirSync + readFileSync at module load) created a transitive node:fs import chain:
lib/ontology/loader.ts → lib/ontology/content-type-registry.ts → lib/validation/schemas.ts → bid-context-provider (client component)That chain pulled node:fs into the client browser bundle and broke bun build on main, cascading to every dependabot PR rebased onto it. Commit 13f30993 (S237) resolves this by inlining the content-type tuple at build time — the generated file imports nothing from node:fs, gray-matter, or the runtime loader, so the consumer chain (registry → validation/schemas → bid-context-provider) is client-bundle-safe.
5.3.2 Build wiring (package.json)
Section titled “5.3.2 Build wiring (package.json)”The codegen runs automatically before every dev start and every production build, so the generated file is always in lockstep with the markdown source:
{ "scripts": { "predev": "bun run generate:skills && bun run generate:content-type-values", "prebuild": "bun run generate:skills && bun run generate:content-type-values", "generate:content-type-values": "bun run scripts/generate-content-type-values.ts", "build:vercel": "bun run generate:skills && bun run generate:content-type-values && bun run build:mcp-apps && next build" }}The pattern mirrors the established generate:skills codegen step. The Vercel build script lists the codegen explicitly (Vercel does not run npm/bun lifecycle hooks reliably on every deploy path, so the explicit invocation is belt-and-braces).
5.3.3 Exports and consumer contract
Section titled “5.3.3 Exports and consumer contract”lib/ontology/content-type-values.generated.ts exports:
CONTENT_TYPE_VALUES: readonly string[]— frozen tuple of the keys indocs/ontology/04-content-type.mdbaseline_values[], in source order.
lib/ontology/content-type-registry.ts re-exports the same symbol. Consumers continue to import from the registry path (@/lib/ontology/content-type-registry) — the codegen split is invisible at the call site.
Active downstream consumers:
lib/validation/schemas.ts:6—import { CONTENT_TYPE_VALUES } from '@/lib/ontology/content-type-registry';thenexport const VALID_CONTENT_TYPES = CONTENT_TYPE_VALUES;(the existing public surface preserved as a re-export so other callers keep working).lib/validation/ingest-schemas.ts:6—import { VALID_CONTENT_TYPES } from './schemas';thenz.enum(VALID_CONTENT_TYPES as readonly [string, ...string[]])(the literal-tuple cast retained as §5.3.5 below describes).
5.3.4 Generator behaviour
Section titled “5.3.4 Generator behaviour”scripts/generate-content-type-values.ts:
readFile('docs/ontology/04-content-type.md', 'utf-8').matter(raw).dataextracts frontmatter viagray-matter.- Hard-validates
cv_name === 'content_type'andArray.isArray(baseline_values) && baseline_values.length > 0; throws with the source path otherwise (surfaces the “wrong file edited” failure loudly at build time, not silently in production). - Maps
baseline_values[].keyand emits a deterministicObject.freeze([...] as const)tuple intolib/ontology/content-type-values.generated.tswith a// AUTO-GENERATEDheader. - Logs the count for build-log debuggability.
5.3.5 Type-widening and the z.enum cast
Section titled “5.3.5 Type-widening and the z.enum cast”The generated tuple is typed as readonly string[] (not readonly ['article', 'blog', ...] as const) because it is constructed at runtime by the codegen. The two existing z.enum(VALID_CONTENT_TYPES) call sites therefore require an explicit literal-tuple cast — the Zod enum() constructor demands readonly [string, ...string[]], not readonly string[]:
// lib/validation/schemas.ts:264 — ItemCreateBodySchema.content_type// lib/validation/ingest-schemas.ts:11 — IngestUrlBodySchema.content_typecontent_type: z.enum(VALID_CONTENT_TYPES as readonly [string, ...string[]]),The cast is safe because the generator throws if baseline_values is empty (§5.3.4 step 3), so the tuple is guaranteed non-empty at build time. Same fix applies to any future consumer using z.enum(...) against the registry. The literal-tuple cast is the canonical KH idiom — see lib/validation/layer-schemas.ts:53 (z.enum(keys as [string, ...string[]])).
5.3.6 Parity guarantees retained
Section titled “5.3.6 Parity guarantees retained”The codegen does not weaken the markdown↔DB parity guarantee. __tests__/lib/ontology/markdown-parity.test.ts (§5.4) asserted this at S238 time by importing the now-retired loader directly (ID-133 amendment — see §5.2); post-ID-133 the equivalent parity guard compares the public fixture, the generated tuple, and taxonomy_snapshot.json instead of reading the markdown register at runtime. Either way, drift between any of the three artefacts — the markdown register (or its public fixture mirror), the generated file, and the live DB CHECK constraint — fails the build:
- Markdown drifts → loader-side validation in test 1 fails.
- Generated file drifts → test 3 set-equality with
CONTENT_TYPE_VALUESfails. - DB CHECK drifts → test 2 set-equality with the snapshot fails.
5.3.7 Knip baseline absorption
Section titled “5.3.7 Knip baseline absorption”The codegen split added a new public-surface re-export. Knip baseline exports count was raised from 47 → 52 in .knip-baseline.json (commit 04684565) to absorb:
lib/ontology/schemas.ts—OntologyCVSchema.lib/ontology/loader.ts—loadOntologyCVs.lib/ontology/content-type-registry.ts—CONTENT_TYPE_VALUES(the re-export shim).lib/ontology/content-type-values.generated.ts—CONTENT_TYPE_VALUES(the generated tuple; knip’s strict re-export detection flags both the source and the shim).lib/validation/schemas.ts—VALID_CONTENT_TYPES(the upstream public surface preserved as a re-export).
All five are wired end-to-end (the consumer chain is validation/schemas.ts → validation/ingest-schemas.ts and downstream Zod-validated route handlers); knip flags them because the consumer is a different package than the declarer. Expected behaviour. See the .knip-baseline.json capturedFromCommit note + the CI runbook §6 procedure for any future re-baselining.
5.4 __tests__/lib/ontology/markdown-parity.test.ts (NEW)
Section titled “5.4 __tests__/lib/ontology/markdown-parity.test.ts (NEW)”Purpose: Vitest parity guard. Mirrors __tests__/validation/schema-db-consistency.test.ts shape. Real-behaviour: no mocks, no test-shape coupling. Each it() title reads as a product spec per docs/reference/test-philosophy.md §1 criterion 5.
Test cases (one describe per CV concern):
every .md file parses + validates against OntologyCVSchema— iteratesloadOntologyCVs()(which itself throws on first failure). Belt-and-braces: the test re-validates per-file and accumulates errors so the developer sees ALL failing files in one run, not one-at-a-time across N reruns. Per__tests__/validation/schema-db-consistency.test.ts:72-88accumulate-and-report pattern.every CV with editable_via=database_migration has baseline_values matching the live DB CHECK— for each such CV, look up the corresponding key intaxonomy_snapshot.json(initial coverage:content_type→snapshot.content_types;platform→snapshot.platforms). Set equality both directions. Skips CVs whose snapshot key is not yet wired (so adding a newdatabase_migrationCV without snapshot support fails loud).content_items.content_type lists exactly 15 values matching the snapshot— explicit spot-check on the canonical case the consumer wires up. ReadsCONTENT_TYPE_VALUESfrom the registry +snapshot.content_types; assertsexpect(sorted).toEqual(sorted)andexpect(values).toHaveLength(15).every cv_name is unique across the register— guards against duplicate filenames or copy-paste errors in YAML.every baseline_values key is unique within its CV— guards against duplicate enum keys per file.
(The previously listed related_layers ⊆ {1..6} test was dropped: it’s strictly redundant with case 1, since the Zod schema already enforces the layer set as a literal union. Case 1’s per-file error message will surface the offending value cleanly.)
Implementation notes:
import { readFileSync } from 'node:fs';+import { join } from 'node:path';for snapshot loading. Mirror the existing__tests__/validation/schema-db-consistency.test.tsshape exactly — samePROJECT_ROOTresolution, samedescribe.skipIf(!snapshot)guard.- No
vi.mock. NocreateMockSupabaseClient. No fixture stubs of the markdown. This test reads the real files. That is the point. - If a markdown file was added but its snapshot key isn’t yet plumbed, the parity test for case 2 should
skipwith an explicittodo-shaped message rather than fail silently — but the existence/format tests (1, 4, 5, 6) still run on every file.
Test command: bun run test __tests__/lib/ontology (per the test-philosophy “always bun run test, never bun test” gotcha).
5.5 scripts/ontology-sync/parse-flow.py (NEW — architectural stub)
Section titled “5.5 scripts/ontology-sync/parse-flow.py (NEW — architectural stub)”Purpose: typed cocoindex flow stub for the future automated markdown→DB sync. NOT executed in CI or dev this session. Demonstrates the shape so the Wave-7 layered-fn finding (inner fns take content_text: str, not FileLike) is preserved against future drift.
Exports / shape:
- A module-level
coco.Appdeclaration namedontology_sync_app(not started). - An outer fn
process_ontology_file(file: FileLike) -> ParsedCVthat reads one markdown file via FileLike — file-tier memo (re-runs on byte change). - An inner fn
parse_cv_frontmatter(content_text: str) -> dictdecorated@coco.fn(memo=True)— content-tier memo (only re-runs when the frontmatter body changes, not on metadata edits to the host file). - A second inner fn
validate_cv_against_yaml(content_text: str, expected_keys: list[str]) -> ValidationReport— pure-Python YAML-shape check. Same content-keyed memo discipline. Deliberately NOT avalidate_cv_against_dbshape — DB validation requires explicit credential wiring (env vars, target table, migration design) that this slice defers. A future engineer who lifts this stub into a live flow adds the DB-tier validation at that point with explicit credential sourcing. - No
coco.start(...)call. No DB writes. No DB reads. The bottom of the file hasif __name__ == "__main__": print("ontology-sync stub — not implemented")so accidental invocation is loud. - A header comment notes: ”// version=N bumps for cascade invalidation NOT shown here; defer to live wiring per S9 §7.2.” Belt-and-braces for the future engineer who lifts the stub.
Dependencies (declared in module docstring, not actually imported in case the dev env doesn’t have cocoindex installed):
cocoindex>=1.0.3— see CLAUDE.md gotcha: install + first-run requiresdangerouslyDisableSandbox: true.pyyaml— for frontmatter parsing.- Credential surface: none in this stub. When the future live flow lands, DB credentials read from
.env.localper the canonical KH pattern (POSTGRES_PASSWORD,SUPABASE_SERVICE_ROLE_KEY); explicit env-var names get documented in the live-flow spec, not here.
Why a stub now: building the live flow needs (a) a DB target table for the parsed CVs, (b) a localfs.walk_dir(recursive=True) source pointed at docs/ontology/, (c) the layered fn-shape ratified by S9. We have (c). We deliberately defer (a) and (b) to a later session because they need migration design + the broader application_types work that lands in the combined PR (Q-OQR1-16). The DB target table specifically lands alongside the application_types migration in Q-OQR1-16, so a future engineer should not independently propose a table-design migration here. The stub locks in the fn-shape and signposts the build sequence.
File header MUST cite:
docs/plans/phase-0-investigation/0.9-spike-S9-cocoindex-idempotency.md§7.2 (layered fn-shape rationale).- The cocoindex sandbox gotcha.
- Build sequence reference (
phase-b-prerequisite-1-onthology-pipeline.md§6 Phase 1).
6. Frontmatter Zod shape (the contract)
Section titled “6. Frontmatter Zod shape (the contract)”This is the exact code the Implementer writes in lib/ontology/schemas.ts. It MUST accept the 29 A1-shipped files verbatim AND match the docs/ontology/README.md lifecycle classification (which is the contract A1 implements — broader than WP-ONTO-R1 §6.3, which omits status).
import { z } from 'zod';
export const PROVENANCE_VALUES = ['core', 'client', 'recommended'] as const;export const PROVENANCE_MODEL_VALUES = ['core', 'client', 'hybrid'] as const;export const EDITABLE_VIA_VALUES = ['database_migration', 'admin_ui', 'seed_data'] as const;export const STATUS_VALUES = ['active', 'planned', 'needed'] as const;export const LAYER_VALUES = [1, 2, 3, 4, 5, 6] as const;
const BaselineValueSchema = z.object({ // Accepts snake_case (canonical), kebab-case (mirrors existing TS module names // like `bid-metadata`, `unified-gap`, `filter-preset`), and the literal `TBD` // sentinel used by the Drafter wave for placeholder rows. The parity test // (§5.4 case 2) catches any TBD-key that survives into the Editor wave for // CVs whose snapshot exists. key: z .string() .min(1) .regex( /^([a-z][a-z0-9_-]*|TBD)$/, 'baseline value key must be snake_case, kebab-case, or the TBD placeholder sentinel', ), label: z.string().min(1), provenance: z.enum(PROVENANCE_VALUES), definition: z.string().min(1).optional(),});
export const OntologyCVSchema = z .object({ // Accepts snake_case (canonical) AND UPPER_SNAKE (e.g. `BID_STATES` in // `14-bid-states.md`). UPPER_SNAKE is allowed because A1 ships at least // one file using it; see §11 for the long-term normalisation note. cv_name: z .string() .min(1) .regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'cv_name must be alphanumeric with underscores'), layer: z.union([ z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6), ]), provenance_model: z.enum(PROVENANCE_MODEL_VALUES), client_extensible: z.boolean(), editable_via: z.enum(EDITABLE_VIA_VALUES), core_seed_path: z.string().min(1).nullable(), status: z.enum(STATUS_VALUES), baseline_values: z.array(BaselineValueSchema).min(1), related_layers: z .array( z.union([ z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6), ]), ) .default([]), }) .strict();
export type OntologyCV = z.infer<typeof OntologyCVSchema>;Notes for the Implementer:
.strict()is load-bearing: Zod’s defaultz.object()silently strips unknown keys. Without.strict()the loader would discard any A1-introduced field (e.g.statusif it weren’t in the schema) and downstream consumers would silently seeundefined..strict()makes drift fail loudly.statusis required: every A1 file declares it perdocs/ontology/README.mdlifecycle classification. Treating it as required prevents the silent-strip surface and lets future consumers (e.g. an admin filter “show only active CVs”) rely on the field being present.provenance_modelacceptscore | client | hybrid(NOTclient_defined). Aligned todocs/ontology/README.mdlines 56-64 + the WP-ONTO-R1 §6.2 vocabulary, which is what A1 actually emits.cv_nameregex toleratesBID_STATES-style upper-case naming because A1 ships14-bid-states.mdwithcv_name: BID_STATES. Long-term, A1 should normalise to snake_case for consistency with the other 28 files (out-of-scope for this slice — see §11).BaselineValueSchema.keyregex deliberately accepts kebab-case keys because A1 has 6 legitimate kebab-case keys mirroring real TS module names (bid-metadata,unified-gap,proposal-placeholder,heading-section,qa-block,filter-preset— all present as live TS module identifiers in the codebase). Forcing snake_case in the markdown would create permanent drift between the ontology and the TS module names. TheTBDliteral is also accepted for Drafter-wave placeholder rows; the parity test catches them once the snapshot key is wired.core_seed_pathisnullable(notoptional) because every YAML doc must declare the field —nullif there is no seed file (closed CORE enums), a string path otherwise. Distinguishing missing-from-doc (a Drafter bug) from intentionally-no-seed (closed enum) is load-bearing.client_extensibleis required and boolean. CORE enums set it tofalse, hybrid CVs set it totrue.definitionon baseline values is optional this session — the Drafter does its best; the Editor fills missing definitions in a later wave. The schema allows but does not require.related_layersdefaults to[]so existing files without it parse cleanly; new files should set it explicitly per the README example.- The explicit
z.union([z.literal(1), ...])shape (rather thanLAYER_VALUES.map(...) as [...]) follows the existing KH idiom for hand-authored layer literals;z.number().int().min(1).max(6)would also work but loses literal-union narrowing for downstreamswitch (layer.layer) { case 1: ... }consumers.
7. Test plan
Section titled “7. Test plan”Behaviour invariants (from §5.4 above; cross-referenced in test titles):
| Invariant | Test case (title verbatim) | Implementation |
|---|---|---|
| Every CV file is parseable + valid | every CV file parses and validates against the ontology schema | loadOntologyCVs() round-trip + per-file re-validate; accumulate per-file errors. Zod already enforces related_layers ⊆ {1..6} so a separate test row is redundant — case 1’s error message surfaces it cleanly. |
Markdown ↔ DB CHECK parity (content_type) | content_items.content_type lists exactly 15 values matching the live DB CHECK | Set equality between CONTENT_TYPE_VALUES and snapshot.content_types; expect(values).toHaveLength(15). |
| Markdown ↔ DB CHECK parity (general) | every CV with editable_via=database_migration matches the live DB CHECK both ways | Loop over CVs; lookup snapshot key by cv_name; assert no extras either side. |
| CV name uniqueness | each cv_name appears in only one ontology file | expect(new Set(names).size).toBe(names.length). |
| Baseline value key uniqueness | each baseline_values key appears once within its CV | Per-CV check. |
Mock discipline (per docs/reference/test-philosophy.md §5.3): none. Boundaries crossed are node:fs (the markdown files) and the on-disk JSON snapshot. Both are real artefacts that already exist in the repo or are produced by bun run sync:taxonomy. Mocking either would defeat the purpose.
Implementation snippets the Implementer can lift wholesale:
import { readdirSync, readFileSync, existsSync } from 'node:fs';import { join } from 'node:path';import { describe, it, expect } from 'vitest';import { loadOntologyCVs, ONTOLOGY_DIR } from '@/lib/ontology/loader';import { OntologyCVSchema } from '@/lib/ontology/schemas';import { CONTENT_TYPE_VALUES } from '@/lib/ontology/content-type-registry';
const PROJECT_ROOT = join(__dirname, '../../..');const SNAPSHOT_PATH = join( PROJECT_ROOT, 'scripts/tests/fixtures/taxonomy_snapshot.json',);const SNAPSHOT_EXISTS = existsSync(SNAPSHOT_PATH);const snapshot: { content_types?: string[]; platforms?: string[] } | null = SNAPSHOT_EXISTS ? JSON.parse(readFileSync(SNAPSHOT_PATH, 'utf8')) : null;
const cvs = loadOntologyCVs();The test file is dense but small (~100 lines). It must NOT exceed ~150 lines — if it grows, prefer extracting per-CV cases into table-driven it.each(...) rather than splitting helpers.
8. Risks and mitigations
Section titled “8. Risks and mitigations”| Risk | Mitigation |
|---|---|
Zod silently strips unknown keys (e.g. a future field added to A1’s frontmatter that isn’t in OntologyCVSchema) | OntologyCVSchema uses .strict(), so unknown keys fail loudly. This is the single most subtle landmine in the schema design — without .strict(), the loader would discard fields without any test failing. See §6 notes. |
| End-to-end consumer wire-up not exercised (“build the thing, forget to turn it on” — CLAUDE.md general gotcha) | The §5.4 parity test imports CONTENT_TYPE_VALUES directly from @/lib/ontology/content-type-registry, AND the existing __tests__/validation/schema-db-consistency.test.ts continues to pass via VALID_CONTENT_TYPES. Both tests passing proves the re-export chain wires end-to-end (markdown → registry → schemas.ts → consumer). Do NOT delete the old test even though there is partial overlap — it proves the existing import path works. |
| Cached array mutated by a downstream consumer | The loader returns readonly OntologyCV[] and the cached array reference is shared across all callers. Mutation would leak across tests in the same Vitest worker. Do NOT mutate; the readonly types are intent, not enforcement. A one-line comment in loader.ts reminds future readers. |
| cocoindex sandbox blocks the parse-flow stub on import | The stub does NOT actually import cocoindex at module level — imports live inside guarded blocks or comments. CI never executes the file. CLAUDE.md gotcha: cocoindex 1.0.3 install + first-run requires dangerouslyDisableSandbox: true. The stub’s docstring repeats this so future Implementers don’t get caught. |
| DB-MD drift discovered at PR time (e.g. MD has 16 values, DB has 15) | The parity test fails the build. The combined-PR Implementer (Q-OQR1-16 wave) MUST bring the DB CHECK migration AND the markdown update together — same PR, same commit if possible. The test prevents one without the other. |
| Bidirectional drift | Test 2 + 3 explicitly check both directions (missing and extra arrays). Replicates the proven pattern from __tests__/validation/schema-db-consistency.test.ts:72-88. |
| Test agent encounters a real DB CHECK that differs from the markdown unexpectedly | Per CLAUDE.md gotcha “Agent escalation rule”: the test agent must escalate to the main session rather than “fix” the markdown to match. The DB is the source of truth in this conflict; the discrepancy may indicate a missed migration or a markdown-authoring error and Liam decides which side to align. |
gray-matter is a new project dep | Tiny (~7 KB), zero runtime warnings, used by next/mdx. Add via bun add gray-matter and commit bun.lock. Knip will not flag it because the loader imports it. |
VALID_CONTENT_TYPES type widens from literal-union tuple to readonly string[] | Audit consumers in §5.3. The two existing z.enum(VALID_CONTENT_TYPES) call sites get an explicit as readonly [string, ...string[]] cast (per §5.3); the rest of the consumers use .includes(...) runtime checks where narrowing was relied on. bun run lint + bun run test are required green-gates before merge. |
| Loader runs on every test file’s import → slow | The cache flag in loader.ts ensures one disk read per process. ~30 small files; <50 ms total. Acceptable. |
docs/ontology/ directory does not yet exist (Drafter wave hasn’t merged) | The loader must throw a clear error if ONTOLOGY_DIR is missing — not silently return []. The parity test then fails loud, which is correct: this session’s Implementer wave assumes the Drafter / Editor waves have already produced the markdown. If they haven’t, the Implementer waits, not fakes it. |
Knip sees parse-flow.py as unused | Knip ignores Python by default. No action needed. |
Future cocoindex version= cascade reminder | Out of scope this slice — but the stub’s header comment notes it so the future engineer who lifts the stub into a live flow remembers the S9 §7.2 cascade-invalidation pattern. |
9. Acceptance criteria
Section titled “9. Acceptance criteria”The Implementer is done when all of the following hold:
lib/ontology/schemas.ts,lib/ontology/loader.ts,lib/ontology/content-type-registry.ts,__tests__/lib/ontology/markdown-parity.test.ts,scripts/ontology-sync/parse-flow.pyall exist with the contents specified above.lib/validation/schemas.tslines 41-57 are replaced per §5.3 (VALID_CONTENT_TYPESre-exports from the registry).gray-matteris added topackage.jsondependenciesandbun.lockis committed.bun run test __tests__/lib/ontology— all parity tests pass.bun run test— full suite green (no regressions from the consumer wire-up).bun lint— zero new errors / warnings.bun run format:check— clean.bun run knip— no new unused-export warnings.- All 29 markdown files referenced by the Drafter wave parse and validate against the spec’s §6 schema as currently authored — including the
BID_STATESupper-casecv_name, the kebab-case keys in19-engineering-types.md/20-chunk-kind.md/15-workspaces-type.md, and theTBDplaceholder rows. The schema accepts these per §6 by design; the parity test (case 2) catches anyTBDrow that survives into a CV with a wired DB snapshot. Thecontent_typeCV in particular contains exactly the 15 values present inscripts/tests/fixtures/taxonomy_snapshot.jsoncontent_types[]. - The cocoindex stub at
scripts/ontology-sync/parse-flow.pyis NOT executed by any CI workflow.
10. Implementer instructions (S236 Wave D)
Section titled “10. Implementer instructions (S236 Wave D)”Pre-flight (one-shot):
git reset --hard origin/content-items-investigationthengit statusto confirm a clean tree. NOTE: the WP6 work lives oncontent-items-investigation, NOTmain. Do NOTgit reset --hard main— that destroys the WP6 commit chain (A1, A2, B1, B2, and this C1 spec edit).- Verify the WP6 Drafter / Editor waves merged:
ls docs/ontology/*.md | wc -lshould print29(or your Drafter’s count). If empty or partial, halt and surface to main session before proceeding. - Verify the snapshot is fresh:
cat scripts/tests/fixtures/taxonomy_snapshot.json | jq '.content_types | length'should print15. If not, runbun run sync:taxonomyfirst.
Build order (creates depend in this order):
lib/ontology/schemas.ts— paste the §6 code verbatim. Runbun run lintto catch typos.bun add gray-matter— adds the dep + updatesbun.lock.lib/ontology/loader.ts— implements the synchronous reader. Add a smalltry { loadOntologyCVs(); console.log('loaded', loadOntologyCVs().length, 'CVs'); } catch (e) { console.error(e); process.exit(1); }snippet at the bottom under anif (import.meta.main)guard. Run viabun run lib/ontology/loader.tsfor a one-shot smoke test BEFORE the Vitest run — this surfaces any per-file Zod failure in <1 second instead of buried inside the test reporter.lib/ontology/content-type-registry.ts— deriveCONTENT_TYPE_VALUESfrom the loader.__tests__/lib/ontology/markdown-parity.test.ts— write the tests. Runbun run test __tests__/lib/ontologyand iterate until green.- Switch the consumer: edit
lib/validation/schemas.tslines 41-57 per §5.3. Apply theas readonly [string, ...string[]]cast at bothlib/validation/schemas.ts:264(ItemCreateBodySchema.content_type) andlib/validation/ingest-schemas.ts:11(IngestUrlBodySchema.content_type) per §5.3 — these are concrete TS compilation errors, NOT a.includes(...)substitution. - Run
bun run test(full suite). Fix any other consumer that broke from the type widening — likely a handful of TS errors at most. Where the literal-union type was used for switch-statement narrowing, convert to runtime.includes(...)checks. scripts/ontology-sync/parse-flow.py— author the stub last; nothing depends on it.
Quality-gate checklist before commit:
-
bun run testgreen. -
bun lintzero new findings. -
bun run format:checkclean. -
bun run knipno new findings. - The pre-existing
__tests__/validation/schema-db-consistency.test.tsstill passes (proves the re-export chainmarkdown → registry → schemas.tswires end-to-end). -
git statusshows only the files listed in §5 pluspackage.json+bun.lock.
Escalation triggers (halt + surface to main session):
- Markdown CHECK constraint mismatch you cannot resolve by following the spec. Per CLAUDE.md “Agent escalation rule”.
- Drafter wave hasn’t produced the 29 markdown files.
- A consumer of
VALID_CONTENT_TYPESexists that genuinely needs the literal-union type and cannot be safely widened. - Drafter output violates the spec’s §6 contract in a way the schema as written does not already accept (e.g. a frontmatter field name unknown to the schema, which
.strict()would reject). Do NOT silently widen the §6 regexes locally to make broken Drafter output pass — surface to main session for a Liam ruling on whether the spec or the Drafter output is the source of truth.
11. Out of scope
Section titled “11. Out of scope”- The 29 markdown files themselves (owned by Drafter / Verifier / Editor waves).
- README.md for
docs/ontology/(owned by Drafter wave). - Migration of any other enum (
VALID_PLATFORMS,VALID_REVIEW_ACTIONS, etc.) to be markdown-driven. - GitBook integration (architectural plan only — lives in the broader WP6 README).
- Live cocoindex flow (engine boot, DB writes, scheduled execution).
- Admin UI for editing client-extensible CVs (deferred to v1.1 per Q-OQR1-13).
- Procurement rename, kb_section retirement, application_types instance table — all part of the combined PR per Q-OQR1-16, NOT this session.
- Generalising the
content-type-registry.tspattern into a parametriclib/ontology/registry.ts<T>factory for the other 28 CVs. Build exactly thecontent_typeregistry this slice. The second CV adopting the pattern (a future session) is when generalisation earns its complexity — perdocs/reference/test-philosophy.md§1 anti-premature-abstraction discipline. The Implementer MUST resist any urge to “while we’re here, also wire up the platform registry” or to emit a generic helper. - Long-term
cv_namenormalisation (e.g.BID_STATES→bid_states). The schema accepts upper-case for now; a future Editor-wave session can normalise A1’s outliers if desired. Not this slice.
End of TECH.md. Spec authored by S236 Wave A2 (tech-spec sub-agent), revised by S236 Wave C1 (tech-editor sub-agent) to apply B2 verifier diffs D-1..D-12 (schema fidelity for the actual A1 frontmatter shape, parser-acceptance for kebab-case + UPPER_SNAKE + TBD keys, explicit as readonly [string, ...string[]] cast guidance for the two existing z.enum(VALID_CONTENT_TYPES) call sites, .strict() to fail loudly on unknown fields, provenance_model: client alignment, dropped redundant test case, simplified layer literal-union shape, tightened cocoindex stub to remove latent DB-credential surface, fixed git pre-flight target, added explicit “do not generalise the registry” guard, added smoke-test step to build order). Further amended in S238 WP3 follow-up (this commit) to document the build-time codegen architecture introduced by S237 commit 13f30993 (Turbopack node:fs client-bundle leak resolution): §3 architecture diagram rewritten, §5.3 fully replaced with a 7-subsection codegen specification, knip baseline 47→52 noted as expected absorption — closes E2 implementation-ratifier Check 6 (PASS-WITH-NOTES). Source documents: phase-b-prerequisite-1-onthology-pipeline.md §2.1 + §6 (29 CVs + Phase 1 build order); phase-b-prerequisite-1-onthology-pipeline-feedback-investigation.md §6.3 (frontmatter shape); docs/ontology/README.md lifecycle classification (the contract A1 actually implements); 0.9-spike-S9-cocoindex-idempotency.md §7.2 (layered cocoindex fn-shape); lib/validation/schemas.ts:6,52 (current registry consumer); lib/validation/ingest-schemas.ts:11 (current z.enum cast site); lib/validation/layer-schemas.ts:53 (canonical KH z.enum(... as [string, ...string[]]) cast idiom); lib/ontology/content-type-values.generated.ts (S237 codegen output); scripts/generate-content-type-values.ts (S237 codegen script); package.json predev/prebuild/build:vercel wiring; commit 13f30993 (S237 CI fix); .knip-baseline.json (47→52 absorption note); __tests__/validation/schema-db-consistency.test.ts (parity test pattern to mirror); scripts/tests/fixtures/taxonomy_snapshot.json (live DB CHECK oracle); docs/reference/test-philosophy.md (six audit criteria); docs/specs/wp6-ontology-harness/verifier-reports/B2-tech-spec-sanity.md (the C1 work order).