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

5.8 KiB

SLICE-1 plan — sidecar storage + dual-read + migration + malformed flag

Just-in-time implementation plan for SLICE-1 of Configurable Collection Metadata (§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.pychange_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 .mds 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).