"""§22.4a configurable collection metadata — field schema + central validation. SLICE-2 of docs/design/2026-06-06-configurable-collection-metadata.md. A collection declares a small **field schema** in its `.collection.yaml` (`fields:` block) so its entries can carry structured metadata — priority, tags, and any custom fields the deployment defines. This module is the **one place** that knows a collection's field shapes (modeled on `registry.py`): - `parse_fields` — normalize + validate the declared schema, leniently: a bad block or a bad field def is skipped with a warning, never raised, so a typo in one field can't nuke the collection mirror (INV-3 spirit). The normalized schema is a plain, JSON-serializable mapping that rides in `collections.config_json` (no DB migration) and is served verbatim by the collection API. - `validate` — check an entry's stored values against the schema, returning a list of advisory `Problem`s. Empty list = clean. Used **advisory at read** (the corpus mirror flags a non-empty result as `metadata_malformed`, INV-3) and is the enforcement point at the **write** boundary (the metadata-edit endpoints land in SLICE-4/5). Field types (v1): `enum` (single scalar, controlled by a required `values:` list), `tags` (a list; free-form unless `values:` given), `text` (a free string). `ref` / `multi-enum` are future (design §2, Q2). Unknown types are ignored with a warning. Keys an entry carries that the schema does **not** declare ride along untouched and are never flagged (INV-7). """ from __future__ import annotations import logging from dataclasses import dataclass from typing import Any log = logging.getLogger(__name__) # §6.3 v1 field types. `ref` (typed cross-entry link) and `multi-enum` are # deferred (design §2, Q2) — declared with an unknown type they're skipped. VALID_FIELD_TYPES = {"enum", "tags", "text"} @dataclass class Problem: """One advisory schema-validation problem against a declared field. `code` is a stable machine token (`not-in-values`, `wrong-type`); `message` is human-facing (surfaced at the write boundary in SLICE-4/5).""" field: str code: str message: str def as_dict(self) -> dict[str, str]: return {"field": self.field, "code": self.code, "message": self.message} # ----- schema parsing (lenient) ----- def parse_fields(raw: Any) -> dict[str, dict]: """Normalize a `.collection.yaml` `fields:` block → `{name: {type, ...}}`. Pure (no I/O). Lenient (INV-3): a non-mapping block yields `{}`; an individual field def that is not a mapping, has an unknown/missing `type`, or is an `enum` without a non-empty `values:` list is **skipped with a warning** — never raised. Order is preserved (facet display order, SLICE-3). The result is plain dicts so it serializes straight into `config_json` and the collection API. """ if not isinstance(raw, dict): if raw is not None: log.warning("metadata_schema: fields block is not a mapping (%s); ignoring", type(raw).__name__) return {} out: dict[str, dict] = {} for name, spec in raw.items(): if not isinstance(spec, dict): log.warning("metadata_schema: field %r def is not a mapping; skipping", name) continue ftype = str(spec.get("type") or "").strip() if ftype not in VALID_FIELD_TYPES: log.warning("metadata_schema: field %r has unknown type %r; skipping", name, ftype) continue values = spec.get("values") norm_values: list[str] | None = None if values is not None: if not isinstance(values, list): log.warning("metadata_schema: field %r values is not a list; ignoring", name) else: norm_values = [str(v) for v in values] if ftype == "enum" and not norm_values: log.warning("metadata_schema: enum field %r needs a non-empty values " "list; skipping", name) continue field_def: dict[str, Any] = {"type": ftype} if norm_values is not None: field_def["values"] = norm_values label = spec.get("label") if label: field_def["label"] = str(label) out[str(name)] = field_def return out # ----- value validation (advisory) ----- def _is_scalar(v: Any) -> bool: return isinstance(v, (str, int, float, bool)) def validate(values: dict[str, Any], fields: dict[str, dict]) -> list[Problem]: """Check an entry's metadata `values` against a collection's field schema. Returns advisory `Problem`s (empty = clean). Only **declared** fields are checked; a field the entry omits is fine (no required fields in v1), and a key the schema doesn't declare rides along untouched (INV-7). With an empty schema, everything is clean (INV-5). Never raises (INV-3). """ problems: list[Problem] = [] for name, spec in fields.items(): if name not in values: continue value = values[name] if value is None: continue ftype = spec.get("type") allowed = spec.get("values") if ftype == "enum": if not _is_scalar(value): problems.append(Problem(name, "wrong-type", f"{name!r} must be a single value, got {type(value).__name__}")) elif allowed is not None and str(value) not in allowed: problems.append(Problem(name, "not-in-values", f"{name!r} value {value!r} is not one of {allowed}")) elif ftype == "tags": if not isinstance(value, list): problems.append(Problem(name, "wrong-type", f"{name!r} must be a list, got {type(value).__name__}")) elif allowed is not None: for member in value: if str(member) not in allowed: problems.append(Problem(name, "not-in-values", f"{name!r} value {member!r} is not one of {allowed}")) elif ftype == "text": if not _is_scalar(value): problems.append(Problem(name, "wrong-type", f"{name!r} must be a string, got {type(value).__name__}")) return problems