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>
6.3 KiB
6.3 KiB
Implementation plan — SLICE-2: collection field schema + central validation
Slice: SLICE-2 of
docs/design/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.yamlfields:block is parsed and stored.metadata_schema.validateexists — 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 defaultdocumentcollection 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-emptyvalues:list.tags— list;values:optional (controlled when present, free-form otherwise).text— free string scalar.
refandmulti-enumare 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 raisingRegistryErroras before. - No DB migration. The normalized
fieldsschema rides in the existingcollections.config_jsoncolumn, exactly likeenabled_models. valuessource for validation. An entry's full metadata mapping (metadata.metadata_dict(entry)=to_frontmatter_dict) —tagscome from the knownEntry.tags; custom fields (e.g.priority) come fromEntry.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;enumwithout a non-emptyvalues: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:elsenot-in-values; a list/non-scalar →wrong-type.tags: must be a list (wrong-typeotherwise); if controlled, each member ∈values:elsenot-in-values.text: must be a scalar string (wrong-typeotherwise). 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: ifraw.get("fields")present,cfg["fields"] = metadata_schema.parse_fields(raw["fields"])(only set when non-empty). Flows intoCollectionEntry.config→config_jsonvia the existingjson.dumps(ce.config)in_upsert_named_collection. - Default collection (
apply_registry) currently writes thecollectionsrow withoutconfig_json. A default collection'sfields:would live inprojects.yaml? No — the design saysfields:is a.collection.yamlblock. The default collection has no.collection.yaml. Decision: default- collection field schemas are out of this slice's happy path (the N=1 default isdocumentwith no fields, INV-5). Leaveapply_registryuntouched; only named collections (with a.collection.yaml) carryfields:. 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 afields:block round-trips intoget_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_collectionsetsout["fields"] = _fields_from_config(config_json)(alongsideenabled_models).api_collections.get_colthen serves it with no change.list_collectionsleft as-is (facets are SLICE-3).- Tests extend
tests/test_collection_helpers.py:get_collectionexposesfields; 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'sfieldsschema 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 intometadata_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 withmetadata_malformed = 1; a conforming entry →0; a collection with no schema →0regardless of extra keys.
5. Verify · version · ship
pytestfull 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 tomain.
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).