# SLICE-1 plan — sidecar storage + dual-read + migration + malformed flag Just-in-time implementation plan for **SLICE-1** of [Configurable Collection Metadata](../2026-06-06-configurable-collection-metadata.md) (§7.2). Authored at the start of the SLICE-1 coding session (session 0084, 2026-06-07), against the code SLICE-0 (v0.46.2) landed. ## Scope (and non-scope) **In:** the storage/compat layer only — sidecar files become the source of truth for entry metadata, with a dual-read parser, an idempotent frontmatter→sidecar migration tool, and a derived `metadata_malformed` flag. **Out (later slices):** the `.collection.yaml` `fields:` schema + validation (SLICE-2), faceted filtering (SLICE-3), the edit/bulk UIs (SLICE-4/5). SLICE-1 maps sidecar values onto the **existing** typed `cached_rfcs` columns; it does not add per-field schema columns or facet aggregation. ## Invariants honored - **INV-6 dual-read:** parser reads the sidecar if present, else legacy top-of-doc frontmatter, with identical resulting in-memory records. - **INV-7 unknown keys ride along:** preserved through parse→serialize and through the migration (never dropped). - **INV-2:** a migrated `.md` body contains no metadata. - **INV-1:** `cached_rfcs` stays a derived, rebuildable index; the sidecar in git is the source of truth. - **INV-3:** bad metadata never hard-fails a read — the entry still loads and the catalog flags it (`metadata_malformed`). - **INV-5 / byte-identity:** a collection with no sidecars behaves exactly as today (legacy frontmatter path); existing entries load identically. ## Components 1. **`app/entry.py` — unknown-key preservation (INV-7).** Add `extra: dict[str, Any]` to `Entry`. `parse()` collects frontmatter keys outside the known set into `extra`; `serialize()` re-emits them after the known keys. Makes frontmatter round-trips lossless. 2. **`app/metadata.py` — new module (sidecar concerns).** - `SIDECAR_SUFFIX = ".meta.yaml"`; `sidecar_name(slug)`, `is_sidecar(name)`, `slug_of_sidecar(name)`. - `metadata_dict(entry) -> dict` — the full metadata mapping (known emit-rules + `extra`), shared by the sidecar writer and the frontmatter serializer. - `sidecar_yaml(entry) -> str` — canonical YAML for a sidecar from `metadata_dict`. - `strip_frontmatter(md_text) -> str` — body-only (drops a leading `---…---` block if present; whole text otherwise). - `parse_sidecar(text) -> tuple[dict, bool]` — lenient: `(values, malformed)`; non-mapping / YAML error → `({}, True)`. - `read_entry(md_text, sidecar_text|None) -> tuple[Entry, bool]` — dual-read: sidecar present → metadata from sidecar values, body from `strip_frontmatter(md_text)`, `malformed` from `parse_sidecar`; absent → `entry.parse(md_text)`, `malformed=False`. 3. **`app/gitea.py` — `change_files(...)` batch commit.** `POST /repos/{owner}/{repo}/contents` (Gitea ChangeFiles) with a `files[]` array of `{operation, path, content(b64), sha?}` — one commit for N files. Backs the migration's "one commit per collection". 4. **`metadata.migrate_collection(gitea, org, repo, subfolder, actor)`.** Lists `/rfcs`; for each `.md` **without** a `.meta.yaml` sibling and **with** legacy frontmatter, batch: create the sidecar (`metadata_dict` → YAML) + update the `.md` to body-only. One ChangeFiles commit per collection. Idempotent (skip entries already migrated; no-op when none remain). Returns a summary (`migrated`, `skipped`, `committed`). > **Deferred (decided mid-slice, after code review):** the **operator > trigger** for this tool (an Owner-gated endpoint) is held back to SLICE-4. > The propose/graduate/mark-reviewed/edit write paths still `entry.parse` the > `.md` directly, so migrating a corpus to body-only `.md`s before those > paths are sidecar-aware would break them (crash / re-introduce > frontmatter). SLICE-1 ships the tool as tested groundwork; SLICE-4 makes > the write paths sidecar-aware (and adds lazy migration) and is where the > trigger belongs (INV-8: engine write paths unchanged this slice). 5. **`app/cache.py` — dual-read in `_refresh_collection_corpus`.** Build a sidecar-by-stem map from the dir listing; for each `.md`, read its sidecar sibling (if any), `metadata.read_entry(...)`, thread `malformed` into `_upsert_cached_rfc(metadata_malformed=…)`. 6. **`backend/migrations/033_metadata_malformed.sql`** — additive `ALTER TABLE cached_rfcs ADD COLUMN metadata_malformed INTEGER NOT NULL DEFAULT 0`. 7. **`app/api.py` — surface the flag.** Add `metadata_malformed` (bool) to the two catalog list dicts and `get_rfc`/`_get_rfc_for_collection`. (Frontend badge + `?malformed=` filter are SLICE-3.) ## Tests (TDD — write first) - `test_metadata.py` (unit, pure): dual-read equivalence (sidecar vs legacy → identical Entry); unknown-key preservation through parse→serialize and through `metadata_dict`; `strip_frontmatter` (with/without frontmatter); `parse_sidecar` malformed cases. - `test_metadata_migration.py` (integration, FakeGitea): migrate a collection → sidecars written + `.md` bodies stripped + one commit; **idempotent** (second run is a no-op); unknown keys preserved in the sidecar. - extend the cache/propose vertical: a collection with a sidecar mirrors from the sidecar; a malformed sidecar sets `metadata_malformed` and still loads the entry (INV-3); a no-sidecar collection is byte-identical to today. ## Release Minor bump **0.46.2 → 0.47.0** (new functionality: sidecar storage + migration tool; non-breaking — additive migration 033, dual-read keeps legacy corpora working, opt-in). §20 CHANGELOG + upgrade-steps: migration 033 auto-applies; running the migration tool per collection is optional (**MAY**).