Skip to content

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.


  • Make the markdown ontology register under docs/ontology/*.md the 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 in lib/validation/schemas.ts stops 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.py so 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.
  • 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.ts to be markdown-driven this session. Only VALID_CONTENT_TYPES switches; 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.

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 by lib/ontology/loader.ts. Both premises are superseded. Since ID-68.27 the register lives in this private docs-site repo at src/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, and lib/ontology/loader.ts is 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-derived scripts/tests/fixtures/taxonomy_snapshot.json → build-time lib/ontology/content-type-values.generated.ts — see the register’s own README.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.)

FileLinesWhy it matters
lib/validation/schemas.ts41-57Defines VALID_CONTENT_TYPES (15 values) — this is the existing hard-coded list the registry replaces.
lib/taxonomy/taxonomy.ts15-1824-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.ts1-90Prior-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.jsontop-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.ts76-114Introspects information_schema for the CHECK constraint; produces the snapshot above. No change required.
supabase/types/database.types.ts624, 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 1Source-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.3Source-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.2Source-of-truth for the layered cocoindex fn-shape. Inner fns must take content_text: str not FileLike.

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 — was client_defined in the A2 draft; A1 ships client.)
  • STATUS_VALUES['active', 'planned', 'needed'] as const. Per docs/ontology/README.md lifecycle classification; every A1 file declares status:.
  • 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.ts is retired per Decision A (specs/id-133-ontology-three-layer-pass/TECH.md §“Decision A”; see the register’s own README.md “Decision-A authority chain”). The register markdown now lives in this private docs-site repo, not the public repo’s docs/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_DIRpath.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-matterNEW dep. Add to package.json dependencies (bun add gray-matter). Justification: standard ecosystem choice for YAML frontmatter; tiny (~7 KB); used by next/mdx and similar. Alternatives considered: hand-rolled regex (brittle), js-yaml direct (no body separation). gray-matter is the right tool.
  • lib/ontology/schemas.ts.

Behaviour:

  1. readdirSync(ONTOLOGY_DIR) filtered to *.md excluding README.md.
  2. For each file: readFileSync(file, 'utf8')matter(content)OntologyCVSchema.parse(matter.data).
  3. Validation failure throws an Error whose message includes the file path and the Zod issue list.
  4. Cache result in module-scope let cached: readonly OntologyCV[] | null so subsequent imports reuse it. The cached array is the SAME reference across all callers — do not mutate. The readonly types signal intent; TypeScript does not enforce immutability at runtime, so a one-line comment in loader.ts reminds future maintainers.
  5. 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:

FileRoleRuntime cost
scripts/generate-content-type-values.tsCodegen 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.tsAuto-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.tsOne-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.tsRetired (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.

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.

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).

lib/ontology/content-type-values.generated.ts exports:

  • CONTENT_TYPE_VALUES: readonly string[] — frozen tuple of the keys in docs/ontology/04-content-type.md baseline_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:6import { CONTENT_TYPE_VALUES } from '@/lib/ontology/content-type-registry'; then export 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:6import { VALID_CONTENT_TYPES } from './schemas'; then z.enum(VALID_CONTENT_TYPES as readonly [string, ...string[]]) (the literal-tuple cast retained as §5.3.5 below describes).

scripts/generate-content-type-values.ts:

  1. readFile('docs/ontology/04-content-type.md', 'utf-8').
  2. matter(raw).data extracts frontmatter via gray-matter.
  3. Hard-validates cv_name === 'content_type' and Array.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).
  4. Maps baseline_values[].key and emits a deterministic Object.freeze([...] as const) tuple into lib/ontology/content-type-values.generated.ts with a // AUTO-GENERATED header.
  5. Logs the count for build-log debuggability.

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_type
content_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[]])).

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_VALUES fails.
  • DB CHECK drifts → test 2 set-equality with the snapshot fails.

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.tsOntologyCVSchema.
  • lib/ontology/loader.tsloadOntologyCVs.
  • lib/ontology/content-type-registry.tsCONTENT_TYPE_VALUES (the re-export shim).
  • lib/ontology/content-type-values.generated.tsCONTENT_TYPE_VALUES (the generated tuple; knip’s strict re-export detection flags both the source and the shim).
  • lib/validation/schemas.tsVALID_CONTENT_TYPES (the upstream public surface preserved as a re-export).

All five are wired end-to-end (the consumer chain is validation/schemas.tsvalidation/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):

  1. every .md file parses + validates against OntologyCVSchema — iterates loadOntologyCVs() (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-88 accumulate-and-report pattern.
  2. every CV with editable_via=database_migration has baseline_values matching the live DB CHECK — for each such CV, look up the corresponding key in taxonomy_snapshot.json (initial coverage: content_typesnapshot.content_types; platformsnapshot.platforms). Set equality both directions. Skips CVs whose snapshot key is not yet wired (so adding a new database_migration CV without snapshot support fails loud).
  3. content_items.content_type lists exactly 15 values matching the snapshot — explicit spot-check on the canonical case the consumer wires up. Reads CONTENT_TYPE_VALUES from the registry + snapshot.content_types; asserts expect(sorted).toEqual(sorted) and expect(values).toHaveLength(15).
  4. every cv_name is unique across the register — guards against duplicate filenames or copy-paste errors in YAML.
  5. 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.ts shape exactly — same PROJECT_ROOT resolution, same describe.skipIf(!snapshot) guard.
  • No vi.mock. No createMockSupabaseClient. 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 skip with an explicit todo-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.App declaration named ontology_sync_app (not started).
  • An outer fn process_ontology_file(file: FileLike) -> ParsedCV that reads one markdown file via FileLike — file-tier memo (re-runs on byte change).
  • An inner fn parse_cv_frontmatter(content_text: str) -> dict decorated @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 a validate_cv_against_db shape — 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 has if __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 requires dangerouslyDisableSandbox: true.
  • pyyaml — for frontmatter parsing.
  • Credential surface: none in this stub. When the future live flow lands, DB credentials read from .env.local per 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).

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 default z.object() silently strips unknown keys. Without .strict() the loader would discard any A1-introduced field (e.g. status if it weren’t in the schema) and downstream consumers would silently see undefined. .strict() makes drift fail loudly.
  • status is required: every A1 file declares it per docs/ontology/README.md lifecycle 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_model accepts core | client | hybrid (NOT client_defined). Aligned to docs/ontology/README.md lines 56-64 + the WP-ONTO-R1 §6.2 vocabulary, which is what A1 actually emits.
  • cv_name regex tolerates BID_STATES-style upper-case naming because A1 ships 14-bid-states.md with cv_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.key regex 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. The TBD literal is also accepted for Drafter-wave placeholder rows; the parity test catches them once the snapshot key is wired.
  • core_seed_path is nullable (not optional) because every YAML doc must declare the field — null if 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_extensible is required and boolean. CORE enums set it to false, hybrid CVs set it to true.
  • definition on 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_layers defaults 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 than LAYER_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 downstream switch (layer.layer) { case 1: ... } consumers.

Behaviour invariants (from §5.4 above; cross-referenced in test titles):

InvariantTest case (title verbatim)Implementation
Every CV file is parseable + validevery CV file parses and validates against the ontology schemaloadOntologyCVs() 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 CHECKSet 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 waysLoop over CVs; lookup snapshot key by cv_name; assert no extras either side.
CV name uniquenesseach cv_name appears in only one ontology fileexpect(new Set(names).size).toBe(names.length).
Baseline value key uniquenesseach baseline_values key appears once within its CVPer-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.

RiskMitigation
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 consumerThe 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 importThe 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 driftTest 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 unexpectedlyPer 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 depTiny (~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 → slowThe 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 unusedKnip ignores Python by default. No action needed.
Future cocoindex version= cascade reminderOut 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.

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.py all exist with the contents specified above.
  • lib/validation/schemas.ts lines 41-57 are replaced per §5.3 (VALID_CONTENT_TYPES re-exports from the registry).
  • gray-matter is added to package.json dependencies and bun.lock is 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_STATES upper-case cv_name, the kebab-case keys in 19-engineering-types.md / 20-chunk-kind.md / 15-workspaces-type.md, and the TBD placeholder rows. The schema accepts these per §6 by design; the parity test (case 2) catches any TBD row that survives into a CV with a wired DB snapshot. The content_type CV in particular contains exactly the 15 values present in scripts/tests/fixtures/taxonomy_snapshot.json content_types[].
  • The cocoindex stub at scripts/ontology-sync/parse-flow.py is NOT executed by any CI workflow.

10. Implementer instructions (S236 Wave D)

Section titled “10. Implementer instructions (S236 Wave D)”

Pre-flight (one-shot):

  1. git reset --hard origin/content-items-investigation then git status to confirm a clean tree. NOTE: the WP6 work lives on content-items-investigation, NOT main. Do NOT git reset --hard main — that destroys the WP6 commit chain (A1, A2, B1, B2, and this C1 spec edit).
  2. Verify the WP6 Drafter / Editor waves merged: ls docs/ontology/*.md | wc -l should print 29 (or your Drafter’s count). If empty or partial, halt and surface to main session before proceeding.
  3. Verify the snapshot is fresh: cat scripts/tests/fixtures/taxonomy_snapshot.json | jq '.content_types | length' should print 15. If not, run bun run sync:taxonomy first.

Build order (creates depend in this order):

  1. lib/ontology/schemas.ts — paste the §6 code verbatim. Run bun run lint to catch typos.
  2. bun add gray-matter — adds the dep + updates bun.lock.
  3. lib/ontology/loader.ts — implements the synchronous reader. Add a small try { loadOntologyCVs(); console.log('loaded', loadOntologyCVs().length, 'CVs'); } catch (e) { console.error(e); process.exit(1); } snippet at the bottom under an if (import.meta.main) guard. Run via bun run lib/ontology/loader.ts for 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.
  4. lib/ontology/content-type-registry.ts — derive CONTENT_TYPE_VALUES from the loader.
  5. __tests__/lib/ontology/markdown-parity.test.ts — write the tests. Run bun run test __tests__/lib/ontology and iterate until green.
  6. Switch the consumer: edit lib/validation/schemas.ts lines 41-57 per §5.3. Apply the as readonly [string, ...string[]] cast at both lib/validation/schemas.ts:264 (ItemCreateBodySchema.content_type) and lib/validation/ingest-schemas.ts:11 (IngestUrlBodySchema.content_type) per §5.3 — these are concrete TS compilation errors, NOT a .includes(...) substitution.
  7. 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.
  8. scripts/ontology-sync/parse-flow.py — author the stub last; nothing depends on it.

Quality-gate checklist before commit:

  • bun run test green.
  • bun lint zero new findings.
  • bun run format:check clean.
  • bun run knip no new findings.
  • The pre-existing __tests__/validation/schema-db-consistency.test.ts still passes (proves the re-export chain markdown → registry → schemas.ts wires end-to-end).
  • git status shows only the files listed in §5 plus package.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_TYPES exists 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.
  • 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.ts pattern into a parametric lib/ontology/registry.ts<T> factory for the other 28 CVs. Build exactly the content_type registry this slice. The second CV adopting the pattern (a future session) is when generalisation earns its complexity — per docs/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_name normalisation (e.g. BID_STATESbid_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).