Files
rfc-app/docs/design/plans/2026-06-07-slice1-sidecar-storage.md
T
Ben Stull f05ee59763 v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool
§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md
(§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with
the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no
sidecars present every corpus stays on the legacy frontmatter path,
byte-identical (N=1 unchanged).

- Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter,
  identical records (INV-6); unknown/forward-compat keys ride along through
  parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar
  (malformed/empty/slug-less) never drops the entry — slug backstopped from the
  filename stem, flagged not lost (INV-3).
- Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles
  commit per collection (new `gitea.change_files`). Tested as a function; its
  Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become
  sidecar-aware first — see the design's SLICE-4 note + INV-8). No production
  trigger ships here, so no corpus is rewritten.
- Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed`
  (additive); the corpus mirror derives it; catalog list + entry-detail APIs
  surface `metadata_malformed`.
- INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild
  instead of dropping forward-compat keys.

Gate: backend 575 passed (28 new: test_metadata / _migration / _cache +
graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 +
upgrade-steps; VERSION + frontend/package.json -> 0.47.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:24:27 -07:00

109 lines
5.8 KiB
Markdown

# 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 `<subfolder>/rfcs`; for each `<slug>.md` **without** a `<slug>.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**).