e336e31812
Collections declare a `fields:` schema in `.collection.yaml`; entries carry typed metadata (enum/tags/text). New central `metadata_schema` module parses the schema leniently (INV-3) and validates entry values — advisory at read (corpus mirror flags violations as `metadata_malformed` without hard-failing), the enforcement point for the write boundary (edit endpoints land SLICE-4/5). - app/metadata_schema.py: parse_fields (lenient/normalizing) + validate - registry.parse_collection_manifest reads `fields:` into collection config - collections.get_collection unpacks `fields`; served by the collection API - cache._refresh_collection_corpus validates each entry advisory-only Non-breaking, opt-in: no `fields:` → unchanged (INV-5); default `document` collection declares none (N=1 unchanged). No DB migration — schema rides in collections.config_json. SLICE-2 of docs/design/2026-06-06-configurable-collection-metadata.md §7.2. Backend suite green (601 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
6.3 KiB
Python
148 lines
6.3 KiB
Python
"""§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
|