# Implementation plan — SLICE-2: collection field schema + central validation **Slice:** SLICE-2 of [`docs/design/2026-06-06-configurable-collection-metadata.md`](../2026-06-06-configurable-collection-metadata.md) §7.2. **Session:** OHM-0085. **Branch:** `worktree-metadata-slice2-schema`. ## Goal / Definition of Done (from the design) - `.collection.yaml` `fields:` block is parsed and stored. - `metadata_schema.validate` exists — **advisory at read**, the enforcement point **at write** (write endpoints land in SLICE-4/5; this slice supplies and read-wires the function). - The schema is served via the collection API (`GET …/collections/{id}`). - A collection with **no `fields:`** behaves exactly as today (INV-5) — the §22.13 default `document` collection sees zero change. ## Design decisions (this slice) - **Field-def shape** (design §6.3): `fields:` is an ordered mapping `{name → {type, values?, label?}}`. `type ∈ {enum, tags, text}` (v1). Order is preserved for facet display (SLICE-3). - `enum` — single scalar; requires a non-empty `values:` list. - `tags` — list; `values:` optional (controlled when present, free-form otherwise). - `text` — free string scalar. - **`ref` and `multi-enum` are out of v1** (design §2 future; Q2 leans "later"). Unknown field types are **ignored with a warning** (design §6.3), not fatal. - **Lenient schema parsing.** A malformed `fields:` block or an individual bad field def is skipped with a warning, never raised — a typo in one field must not nuke the collection mirror (INV-3 spirit). Structural manifest errors (`type`, `visibility`) keep raising `RegistryError` as before. - **No DB migration.** The normalized `fields` schema rides in the existing `collections.config_json` column, exactly like `enabled_models`. - **`values` source for validation.** An entry's full metadata mapping (`metadata.metadata_dict(entry)` = `to_frontmatter_dict`) — `tags` come from the known `Entry.tags`; custom fields (e.g. `priority`) come from `Entry.extra`. Undeclared keys are forward-compat (INV-7) and never flagged. ## Tasks ### 1. `app/metadata_schema.py` (new) — the one place that knows field shapes - `VALID_FIELD_TYPES = {"enum", "tags", "text"}`. - `parse_fields(raw) -> dict[str, dict]` — lenient/normalizing. Returns an ordered mapping of `{name: {"type", "values"?, "label"?}}`. Skips: non-mapping block; non-mapping field def; unknown/missing type; `enum` without a non-empty `values:` list. Logs a warning per skip. Pure (no I/O). - `@dataclass Problem(field, code, message)` + `as_dict()`. - `validate(values: dict, fields: dict[str, dict]) -> list[Problem]` — for each **declared** field, validate the entry's value (absent is OK): - `enum`: scalar ∈ `values:` else `not-in-values`; a list/non-scalar → `wrong-type`. - `tags`: must be a list (`wrong-type` otherwise); if controlled, each member ∈ `values:` else `not-in-values`. - `text`: must be a scalar string (`wrong-type` otherwise). Undeclared keys ignored (INV-7). Never raises. - **Tests** `tests/test_metadata_schema.py`: parse (each type, missing block, enum-without-values skipped, unknown type skipped, order preserved); validate (happy, enum bad value, tags uncontrolled-ok, tags controlled-bad, text wrong type, undeclared key ignored, empty schema → no problems). ### 2. `registry.py` — parse `fields:` into the collection config - In `parse_collection_manifest`, after the existing keys: if `raw.get("fields")` present, `cfg["fields"] = metadata_schema.parse_fields(raw["fields"])` (only set when non-empty). Flows into `CollectionEntry.config` → `config_json` via the existing `json.dumps(ce.config)` in `_upsert_named_collection`. - **Default collection** (`apply_registry`) currently writes the `collections` row **without** `config_json`. A default collection's `fields:` would live in `projects.yaml`? No — the design says `fields:` is a `.collection.yaml` block. The default collection has no `.collection.yaml`. **Decision:** default- collection field schemas are out of this slice's happy path (the N=1 default is `document` with no fields, INV-5). Leave `apply_registry` untouched; only named collections (with a `.collection.yaml`) carry `fields:`. Documented as a known limitation (a deployment wanting fields on its primary corpus declares a named collection — consistent with the design's opt-in story). - **Tests** extend `tests/test_collection_registry.py`: a manifest with a `fields:` block round-trips into `get_collection(...)["fields"]`; a manifest with a bad field def still upserts (lenient). ### 3. `collections.py` — unpack `fields` on read + serve via API - Add `_fields_from_config(config_json) -> dict | None` (mirror `_enabled_models_from_config`). - `get_collection` sets `out["fields"] = _fields_from_config(config_json)` (alongside `enabled_models`). `api_collections.get_col` then serves it with no change. `list_collections` left as-is (facets are SLICE-3). - **Tests** extend `tests/test_collection_helpers.py`: `get_collection` exposes `fields`; a no-fields collection → `fields is None` (INV-5). ### 4. `cache.py` — advisory validation at ingest (INV-3) - In `_refresh_collection_corpus`, fetch the collection's `fields` schema once (`collections.get_collection(collection_id)`); for each entry, if a schema is present, `problems = metadata_schema.validate(metadata.metadata_dict(entry), fields)` and OR any problems into `metadata_malformed` (warn-log a summary). No schema → behavior identical to today (INV-5). - **Tests** `tests/test_metadata_cache.py` (extend): an entry violating an enum field ingests with `metadata_malformed = 1`; a conforming entry → `0`; a collection with no schema → `0` regardless of extra keys. ### 5. Verify · version · ship - `pytest` full backend suite green (575 baseline + new). - Bump `VERSION` + `frontend/package.json` → **0.48.0**; CHANGELOG minor entry (§20) — non-breaking, opt-in, N=1 unchanged; note write-enforcement lands with SLICE-4/5. - Commit (cite design §7.2 SLICE-2), PR on Gitea `origin`, merge to `main`. ## Invariants honored INV-3 (read never hard-fails — advisory malformed), INV-5 (no-`fields:` unchanged), INV-7 (undeclared keys ride along, never flagged), INV-8 (additive, read-mostly; no write-path fork — write enforcement is a later slice).