From 49981e2d6e6d75daf7e11b4cf8a6f11509aa7a76 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 18:44:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice4):=20metadata=20sidecar=20git=20read?= =?UTF-8?q?/write=20helpers=20=E2=80=94=20dual-read=20+=20lazy-migrate=20o?= =?UTF-8?q?ps=20(=C2=A722.4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_values, EntryGitState, read_entry_from_git, write_entry_files, sidecar_path_for. Plus the SLICE-4 implementation plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/metadata.py | 94 ++ backend/tests/test_metadata.py | 24 + backend/tests/test_metadata_gitio.py | 108 ++ ...06-07-slice4-single-entry-metadata-edit.md | 1482 +++++++++++++++++ 4 files changed, 1708 insertions(+) create mode 100644 backend/tests/test_metadata_gitio.py create mode 100644 docs/superpowers/plans/2026-06-07-slice4-single-entry-metadata-edit.md diff --git a/backend/app/metadata.py b/backend/app/metadata.py index 0ab3d3b..d8eefea 100644 --- a/backend/app/metadata.py +++ b/backend/app/metadata.py @@ -20,6 +20,7 @@ values — it only moves metadata between git and in-memory `Entry` records. """ from __future__ import annotations +from dataclasses import dataclass from typing import Any import yaml @@ -46,6 +47,12 @@ def slug_of_sidecar(name: str) -> str: return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name +def sidecar_path_for(md_path: str) -> str: + """The sidecar path sibling to a `/.md` entry file.""" + assert md_path.endswith(".md"), md_path + return md_path[: -len(".md")] + SIDECAR_SUFFIX + + # ----- metadata <-> sidecar ----- def metadata_dict(entry: Entry) -> dict[str, Any]: @@ -136,6 +143,93 @@ def read_entry( return _with_slug(entry_mod.from_frontmatter(values, body)), False +# ----- value editing (SLICE-4) ----- + +def apply_values(entry: Entry, values: dict[str, Any]) -> Entry: + """Return a new Entry with `values` merged over the entry's metadata. + + Known keys (`tags`, `state`, `reviewed_by`, …) land on their typed fields; + unknown keys land on `extra` (INV-7). The body is carried through unchanged + — this mutates metadata only. Unspecified keys are preserved. + """ + merged = metadata_dict(entry) + merged.update(values) + return entry_mod.from_frontmatter(merged, entry.body) + + +# ----- git-aware read/write (SLICE-4) ----- + +@dataclass +class EntryGitState: + """An entry's on-disk state across its `.md` and optional sidecar. + + Captured by `read_entry_from_git` and consumed by `write_entry_files` to + decide create-vs-update for the sidecar and whether the `.md` still needs + its frontmatter stripped (lazy migration). + """ + entry: Entry + md_text: str + md_sha: str + sidecar_text: str | None + sidecar_sha: str | None + malformed: bool + + +async def read_entry_from_git( + gitea: Any, org: str, repo: str, md_path: str, *, ref: str = "main" +) -> "EntryGitState | None": + """Dual-read an entry from git → `EntryGitState`, or None if the `.md` is + missing. Reads the `.md` and its sibling sidecar (if any); never raises on + bad metadata (INV-3).""" + md = await gitea.read_file(org, repo, md_path, ref=ref) + if md is None: + return None + md_text, md_sha = md + sc_path = sidecar_path_for(md_path) + sc = await gitea.read_file(org, repo, sc_path, ref=ref) + sidecar_text, sidecar_sha = (sc[0], sc[1]) if sc else (None, None) + stem = md_path.rsplit("/", 1)[-1][: -len(".md")] + entry, malformed = read_entry(md_text, sidecar_text, fallback_slug=stem) + return EntryGitState( + entry=entry, md_text=md_text, md_sha=md_sha, + sidecar_text=sidecar_text, sidecar_sha=sidecar_sha, malformed=malformed, + ) + + +def _md_has_frontmatter(md_text: str) -> bool: + return entry_mod.FRONTMATTER_RE.match(md_text) is not None + + +def write_entry_files( + md_path: str, entry: Entry, state: "EntryGitState" +) -> list[dict[str, Any]]: + """Produce `change_files` ops that persist `entry`'s metadata to its sidecar + and keep the `.md` as pure prose (INV-1/INV-2). + + - Sidecar: `create` when none existed, else `update` at its prior sha. + - `.md`: rewritten body-only **only when it still carries frontmatter** + (lazy migration, INV-6); an already-clean body is left untouched. + """ + sc_path = sidecar_path_for(md_path) + ops: list[dict[str, Any]] = [] + sc_op: dict[str, Any] = { + "operation": "update" if state.sidecar_sha else "create", + "path": sc_path, + "content": sidecar_yaml(entry), + } + if state.sidecar_sha: + sc_op["sha"] = state.sidecar_sha + ops.append(sc_op) + if _md_has_frontmatter(state.md_text): + body = strip_frontmatter(state.md_text) + new_md = body if (body == "" or body.endswith("\n")) else body + "\n" + ops.append({ + "operation": "update", "path": md_path, + "content": new_md, "sha": state.md_sha, + }) + return ops + + # ----- frontmatter -> sidecar migration (PUC-5) ----- async def migrate_collection( diff --git a/backend/tests/test_metadata.py b/backend/tests/test_metadata.py index b5e1473..25d6ddc 100644 --- a/backend/tests/test_metadata.py +++ b/backend/tests/test_metadata.py @@ -197,3 +197,27 @@ def test_sidecar_filename_helpers(): assert metadata.is_sidecar("view-metrics.meta.yaml") is True assert metadata.is_sidecar("view-metrics.md") is False assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics" + + +# ---- SLICE-4: sidecar_path_for + apply_values ---- + +def test_sidecar_path_for_derives_sibling(): + assert metadata.sidecar_path_for("rfcs/alpha.md") == "rfcs/alpha.meta.yaml" + assert metadata.sidecar_path_for("x/y/beta.md") == "x/y/beta.meta.yaml" + + +def test_apply_values_updates_known_and_extra_fields(): + e = entry_mod.parse(LEGACY_MD) # has tags + extra priority/owner + e2 = metadata.apply_values(e, {"tags": ["x"], "priority": "P0", "owner": "sam"}) + assert e2.tags == ["x"] + assert e2.extra["priority"] == "P0" + assert e2.extra["owner"] == "sam" + # body preserved unchanged + assert e2.body == e.body + + +def test_apply_values_preserves_unspecified_keys(): + e = entry_mod.parse(LEGACY_MD) + e2 = metadata.apply_values(e, {"priority": "P0"}) + assert e2.tags == e.tags # untouched + assert e2.extra["owner"] == "hasan" # untouched diff --git a/backend/tests/test_metadata_gitio.py b/backend/tests/test_metadata_gitio.py new file mode 100644 index 0000000..e93f350 --- /dev/null +++ b/backend/tests/test_metadata_gitio.py @@ -0,0 +1,108 @@ +"""SLICE-4 — git-aware sidecar read/write helpers. + +Uses the FakeGitea from the propose-vertical fixtures (no network). +""" +from __future__ import annotations + +import asyncio + +import yaml + +from app import gitea as gitea_mod, metadata +from app.config import load_config + +from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401 + +LEGACY = """--- +slug: alpha +title: Alpha +state: active +owners: +- ben.stull +tags: +- one +priority: P1 +--- + +Alpha body. +""" + +MIGRATED_MD = "Alpha body.\n" +MIGRATED_SIDECAR = """slug: alpha +title: Alpha +state: active +owners: +- ben.stull +tags: +- one +priority: P1 +""" + + +def _gitea(): + return gitea_mod.Gitea(load_config()) + + +def test_read_entry_from_git_legacy(app_with_fake_gitea): + _app, fake = app_with_fake_gitea + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + assert st is not None + assert st.entry.slug == "alpha" + assert st.entry.extra["priority"] == "P1" + assert st.sidecar_sha is None # no sidecar yet + assert st.malformed is False + + +def test_read_entry_from_git_migrated(app_with_fake_gitea): + _app, fake = app_with_fake_gitea + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": MIGRATED_MD, "sha": "s1"} + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = { + "content": MIGRATED_SIDECAR, "sha": "s2"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + assert st.entry.extra["priority"] == "P1" # from sidecar + assert st.entry.body == "Alpha body.\n" + assert st.sidecar_sha == "s2" + + +def test_read_entry_from_git_missing(app_with_fake_gitea): + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/nope.md")) + assert st is None + + +def test_write_entry_files_lazy_migrates_legacy(app_with_fake_gitea): + _app, fake = app_with_fake_gitea + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"priority": "P0"}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + paths = {o["path"]: o for o in ops} + # sidecar created, .md rewritten body-only + assert "rfcs/alpha.meta.yaml" in paths + assert paths["rfcs/alpha.meta.yaml"]["operation"] == "create" + assert paths["rfcs/alpha.md"]["operation"] == "update" + assert "---" not in paths["rfcs/alpha.md"]["content"] # INV-2 clean body + assert yaml.safe_load(paths["rfcs/alpha.meta.yaml"]["content"])["priority"] == "P0" + + +def test_write_entry_files_already_migrated_touches_sidecar_only(app_with_fake_gitea): + _app, fake = app_with_fake_gitea + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": MIGRATED_MD, "sha": "s1"} + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = { + "content": MIGRATED_SIDECAR, "sha": "s2"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"priority": "P0"}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + paths = {o["path"]: o for o in ops} + assert set(paths) == {"rfcs/alpha.meta.yaml"} # .md untouched + assert paths["rfcs/alpha.meta.yaml"]["operation"] == "update" + assert paths["rfcs/alpha.meta.yaml"]["sha"] == "s2" diff --git a/docs/superpowers/plans/2026-06-07-slice4-single-entry-metadata-edit.md b/docs/superpowers/plans/2026-06-07-slice4-single-entry-metadata-edit.md new file mode 100644 index 0000000..6537033 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-slice4-single-entry-metadata-edit.md @@ -0,0 +1,1482 @@ +# SLICE-4 — Single-Entry Metadata Edit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an authorized user edit one entry's schema-defined metadata from the +detail view, committed directly to its `.meta.yaml` sidecar; and make every +existing entry write path sidecar-aware so a migrated (body-only) `.md` never +crashes `entry.parse` or re-grows frontmatter — unblocking the Owner-gated +collection-migration operator endpoint. + +**Architecture:** A new `metadata` read/write helper layer turns "read `.md` → +mutate → serialize `.md`" into "dual-read (`.md`+sidecar) → mutate → write sidecar ++ lazy-migrate `.md` to body-only, in one `change_files` commit." Direct-commit +paths (`mark_entry_reviewed`, the new `POST .../meta`) commit to `main`; PR paths +(graduate, claim, the existing `/metadata` edit) commit to a branch via a shared +bot primitive and open a PR. The frontend detail view renders schema-driven +controls (enum→select, tags→chips, text→input) and POSTs partial values. + +**Tech Stack:** Python 3.13 / FastAPI / SQLite (backend), pytest; React + Vite + +Vitest (frontend); Gitea content repo + sidecar YAML as source of truth. + +**Spec:** `docs/design/2026-06-06-configurable-collection-metadata.md` §7.2 SLICE-4, +§6.4 interfaces, INV-1..INV-8. **Baseline:** `main`@`7ece6d3`, VERSION `0.49.0`. + +**Conventions:** +- Run backend tests from `backend/`: `.venv/bin/pytest tests/ -q` (the suite + is 615 green at baseline). Run the whole suite at phase boundaries. +- Run frontend tests from `frontend/`: `npm test -- --run ` (Vitest). +- Branch for the work: `git checkout -b worktree-metadata-slice4`. Commit per task. +- Commits cite the spec (`§22.4a SLICE-4`) and carry a `Co-Authored-By` trailer. + +--- + +## File Structure + +**Backend — new code:** +- `backend/app/metadata.py` — add git-aware read/write helpers (`sidecar_path_for`, + `read_entry_from_git`, `write_entry_files`, `apply_values`). One responsibility: + move entry metadata between git and `Entry`, sidecar-first. +- `backend/app/api_metadata.py` *(new module)* — the `POST .../meta` edit endpoint + and the Owner-gated `POST .../migrate` operator endpoint (a `make_router` mounted + in `api.py`). SLICE-5's bulk endpoint will land here later. + +**Backend — modified:** +- `backend/app/bot.py` — add `commit_entry_files` (multi-file commit to a branch or + main) + `open_entry_pr` (branch + commit + PR); make `mark_entry_reviewed` + sidecar-aware. +- `backend/app/api_graduation.py` — `graduate`, `claim_ownership`, `retire_rfc`, + `unretire_rfc`, `_read_meta_entry`, `_orchestrate` → sidecar-aware. +- `backend/app/api_branches.py` — `_extract_body`, `_wrap_body`, `edit_metadata` + → sidecar-aware. +- `backend/app/api_prs.py` — `_extract_body_for_replay`, `_wrap_body_for_replay` + → sidecar-aware. +- `backend/app/api.py` — `_serialize_rfc` adds `meta`; `_get_rfc_for_collection` + adds `can_edit_meta`; mount `api_metadata` router. + +**Frontend — new code:** +- `frontend/src/components/MetadataFieldsPanel.jsx` *(new)* — schema-driven + view/edit panel. +- `frontend/src/components/MetadataFieldsPanel.test.jsx` *(new)* — Vitest. + +**Frontend — modified:** +- `frontend/src/api.js` — `saveEntryMeta(...)`. +- `frontend/src/components/RFCView.jsx` — fetch collection `fields`, render the panel. + +**Docs:** +- `VERSION`, `frontend/package.json` — `0.50.0`. +- `CHANGELOG.md` — SLICE-4 entry + upgrade steps. +- `SPEC.md` — §22.4a / §9.5 pointer note (edit + migrate endpoints shipped). + +--- + +## Phase 1 — Sidecar-aware read/write helpers (`metadata.py`) + +These are the foundation every write path uses. Pure where possible; the one +git-touching helper is thin and unit-tested with `FakeGitea`. + +### Task 1.1: `sidecar_path_for` + `apply_values` + +**Files:** +- Modify: `backend/app/metadata.py` +- Test: `backend/tests/test_metadata.py` + +- [ ] **Step 1: Write failing tests** + +In `backend/tests/test_metadata.py` add: + +```python +def test_sidecar_path_for_derives_sibling(): + assert metadata.sidecar_path_for("rfcs/alpha.md") == "rfcs/alpha.meta.yaml" + assert metadata.sidecar_path_for("x/y/beta.md") == "x/y/beta.meta.yaml" + + +def test_apply_values_updates_known_and_extra_fields(): + e = entry_mod.parse(LEGACY_MD) # has tags + extra priority/owner + e2 = metadata.apply_values(e, {"tags": ["x"], "priority": "P0", "owner": "sam"}) + assert e2.tags == ["x"] + assert e2.extra["priority"] == "P0" + assert e2.extra["owner"] == "sam" + # body preserved unchanged + assert e2.body == e.body + + +def test_apply_values_preserves_unspecified_keys(): + e = entry_mod.parse(LEGACY_MD) + e2 = metadata.apply_values(e, {"priority": "P0"}) + assert e2.tags == e.tags # untouched + assert e2.extra["owner"] == "hasan" # untouched +``` + +- [ ] **Step 2: Run to verify FAIL** + +Run: `.venv/bin/pytest tests/test_metadata.py -q -k "sidecar_path_for or apply_values"` +Expected: FAIL (AttributeError: module has no attribute `sidecar_path_for`). + +- [ ] **Step 3: Implement** + +In `backend/app/metadata.py`, after `slug_of_sidecar`, add: + +```python +def sidecar_path_for(md_path: str) -> str: + """The sidecar path sibling to a `/.md` entry file.""" + assert md_path.endswith(".md"), md_path + return md_path[: -len(".md")] + SIDECAR_SUFFIX +``` + +After `read_entry`, add: + +```python +def apply_values(entry: Entry, values: dict[str, Any]) -> Entry: + """Return a new Entry with `values` merged over the entry's metadata. + + Known keys (`tags`, `title`, …) land on their typed fields; unknown keys + land on `extra` (INV-7). The body is carried through unchanged — this + mutates metadata only. Unspecified keys are preserved. + """ + merged = metadata_dict(entry) + merged.update(values) + return entry_mod.from_frontmatter(merged, entry.body) +``` + +- [ ] **Step 4: Run to verify PASS** + +Run: `.venv/bin/pytest tests/test_metadata.py -q -k "sidecar_path_for or apply_values"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/metadata.py backend/tests/test_metadata.py +git commit -m "feat(slice4): metadata.sidecar_path_for + apply_values (§22.4a)" +``` + +### Task 1.2: `read_entry_from_git` + `write_entry_files` + +**Files:** +- Modify: `backend/app/metadata.py` +- Test: `backend/tests/test_metadata_gitio.py` *(new)* + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_metadata_gitio.py`: + +```python +"""SLICE-4 — git-aware sidecar read/write helpers. + +Uses the FakeGitea from the propose-vertical fixtures (no network). +""" +from __future__ import annotations + +import asyncio + +import yaml + +from app import gitea as gitea_mod, metadata +from app.config import load_config + +from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401 + +LEGACY = """--- +slug: alpha +title: Alpha +state: active +owners: +- ben.stull +tags: +- one +priority: P1 +--- + +Alpha body. +""" + +MIGRATED_MD = "Alpha body.\n" +MIGRATED_SIDECAR = """slug: alpha +title: Alpha +state: active +owners: +- ben.stull +tags: +- one +priority: P1 +""" + + +def _gitea(): + return gitea_mod.Gitea(load_config()) + + +def test_read_entry_from_git_legacy(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + assert st is not None + assert st.entry.slug == "alpha" + assert st.entry.extra["priority"] == "P1" + assert st.sidecar_sha is None # no sidecar yet + assert st.malformed is False + + +def test_read_entry_from_git_migrated(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": MIGRATED_MD, "sha": "s1"} + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = { + "content": MIGRATED_SIDECAR, "sha": "s2"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + assert st.entry.extra["priority"] == "P1" # from sidecar + assert st.entry.body == "Alpha body." + assert st.sidecar_sha == "s2" + + +def test_read_entry_from_git_missing(app_with_fake_gitea): + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/nope.md")) + assert st is None + + +def test_write_entry_files_lazy_migrates_legacy(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"priority": "P0"}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + paths = {o["path"]: o for o in ops} + # sidecar created, .md rewritten body-only + assert "rfcs/alpha.meta.yaml" in paths + assert paths["rfcs/alpha.meta.yaml"]["operation"] == "create" + assert paths["rfcs/alpha.md"]["operation"] == "update" + assert "---" not in paths["rfcs/alpha.md"]["content"] # INV-2 clean body + assert yaml.safe_load(paths["rfcs/alpha.meta.yaml"]["content"])["priority"] == "P0" + + +def test_write_entry_files_already_migrated_touches_sidecar_only(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": MIGRATED_MD, "sha": "s1"} + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = { + "content": MIGRATED_SIDECAR, "sha": "s2"} + st = asyncio.run(metadata.read_entry_from_git( + _gitea(), "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"priority": "P0"}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + paths = {o["path"]: o for o in ops} + assert set(paths) == {"rfcs/alpha.meta.yaml"} # .md untouched + assert paths["rfcs/alpha.meta.yaml"]["operation"] == "update" + assert paths["rfcs/alpha.meta.yaml"]["sha"] == "s2" +``` + +- [ ] **Step 2: Run to verify FAIL** + +Run: `.venv/bin/pytest tests/test_metadata_gitio.py -q` +Expected: FAIL (no `read_entry_from_git`). + +- [ ] **Step 3: Implement** + +In `backend/app/metadata.py` add the dataclass + helpers (near the top add +`from dataclasses import dataclass`): + +```python +@dataclass +class EntryGitState: + """An entry's on-disk state across its `.md` and optional sidecar. + + Captured by `read_entry_from_git` and consumed by `write_entry_files` to + decide create-vs-update for the sidecar and whether the `.md` still needs + its frontmatter stripped (lazy migration). + """ + entry: Entry + md_text: str + md_sha: str + sidecar_text: str | None + sidecar_sha: str | None + malformed: bool + + +async def read_entry_from_git( + gitea: Any, org: str, repo: str, md_path: str, *, ref: str = "main" +) -> "EntryGitState | None": + """Dual-read an entry from git → `EntryGitState`, or None if the `.md` is + missing. Reads the `.md` and its sibling sidecar (if any); never raises on + bad metadata (INV-3).""" + md = await gitea.read_file(org, repo, md_path, ref=ref) + if md is None: + return None + md_text, md_sha = md + sc_path = sidecar_path_for(md_path) + sc = await gitea.read_file(org, repo, sc_path, ref=ref) + sidecar_text, sidecar_sha = (sc[0], sc[1]) if sc else (None, None) + stem = md_path.rsplit("/", 1)[-1][: -len(".md")] + entry, malformed = read_entry(md_text, sidecar_text, fallback_slug=stem) + return EntryGitState( + entry=entry, md_text=md_text, md_sha=md_sha, + sidecar_text=sidecar_text, sidecar_sha=sidecar_sha, malformed=malformed, + ) + + +def _md_has_frontmatter(md_text: str) -> bool: + return entry_mod.FRONTMATTER_RE.match(md_text) is not None + + +def write_entry_files( + md_path: str, entry: Entry, state: "EntryGitState" +) -> list[dict[str, Any]]: + """Produce `change_files` ops that persist `entry`'s metadata to its sidecar + and keep the `.md` as pure prose (INV-1/INV-2). + + - Sidecar: `create` when none existed, else `update` at its prior sha. + - `.md`: rewritten body-only **only when it still carries frontmatter** + (lazy migration, INV-6); an already-clean body is left untouched. + """ + sc_path = sidecar_path_for(md_path) + ops: list[dict[str, Any]] = [] + sc_op: dict[str, Any] = { + "operation": "update" if state.sidecar_sha else "create", + "path": sc_path, + "content": sidecar_yaml(entry), + } + if state.sidecar_sha: + sc_op["sha"] = state.sidecar_sha + ops.append(sc_op) + if _md_has_frontmatter(state.md_text): + body = strip_frontmatter(state.md_text) + new_md = body if (body == "" or body.endswith("\n")) else body + "\n" + ops.append({ + "operation": "update", "path": md_path, + "content": new_md, "sha": state.md_sha, + }) + return ops +``` + +- [ ] **Step 4: Run to verify PASS** + +Run: `.venv/bin/pytest tests/test_metadata_gitio.py -q` +Expected: PASS (6 passed). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/metadata.py backend/tests/test_metadata_gitio.py +git commit -m "feat(slice4): metadata git read/write helpers — dual-read + lazy-migrate ops (§22.4a)" +``` + +--- + +## Phase 2 — Bot multi-file commit + PR primitives + +PR-based write paths must commit two files (sidecar + body-only `.md`) in one +commit on a branch. Add a generic primitive so graduate/claim/edit reuse it. + +### Task 2.1: `bot.commit_entry_files` + `bot.open_entry_pr` + +**Files:** +- Modify: `backend/app/bot.py` +- Test: `backend/tests/test_bot_entry_files.py` *(new)* + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_bot_entry_files.py`: + +```python +"""SLICE-4 — bot multi-file commit + PR primitives.""" +from __future__ import annotations + +import asyncio + +from app import bot as bot_mod, gitea as gitea_mod, metadata +from app.bot import Actor +from app.config import load_config + +from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401 + +LEGACY = "---\nslug: alpha\ntitle: Alpha\nstate: active\ntags:\n- one\n---\n\nBody.\n" + + +def _actor(): + return Actor(gitea_login="ben.stull", display_name="Ben", email="ben@x.io") + + +def test_commit_entry_files_direct_to_main(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + cfg = load_config() + gitea = gitea_mod.Gitea(cfg) + bot = bot_mod.Bot(cfg, gitea) + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"tags": ["two"]}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + asyncio.run(bot.commit_entry_files( + _actor(), org="wiggleverse", repo="meta", files=ops, + message="Edit metadata", branch="main")) + sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"] + assert "two" in sc + # .md is now body-only + assert "---" not in fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] + + +def test_open_entry_pr_commits_on_branch_and_opens_pr(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + cfg = load_config() + gitea = gitea_mod.Gitea(cfg) + bot = bot_mod.Bot(cfg, gitea) + fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = { + "content": LEGACY, "sha": "s1"} + st = asyncio.run(metadata.read_entry_from_git(gitea, "wiggleverse", "meta", "rfcs/alpha.md")) + e2 = metadata.apply_values(st.entry, {"tags": ["two"]}) + ops = metadata.write_entry_files("rfcs/alpha.md", e2, st) + pr = asyncio.run(bot.open_entry_pr( + _actor(), org="wiggleverse", repo="meta", slug="alpha", files=ops, + pr_title="Metadata: Alpha", pr_description="edit", branch_prefix="metadata")) + assert pr["number"] >= 1 + head = pr["head"]["ref"] + assert head.startswith("metadata-alpha-") + # committed on the branch, main untouched + assert ("wiggleverse", "meta", head, "rfcs/alpha.meta.yaml") in fake.files + assert ("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml") not in fake.files +``` + +- [ ] **Step 2: Run to verify FAIL** + +Run: `.venv/bin/pytest tests/test_bot_entry_files.py -q` +Expected: FAIL (no `commit_entry_files`). + +- [ ] **Step 3: Implement** + +In `backend/app/bot.py`, add to the `Bot` class (near `open_metadata_pr`). Match +the existing `_stamp`/`_stamp_single`/`_log` helpers already used in that file: + +```python + async def commit_entry_files( + self, actor: Actor, *, org: str, repo: str, + files: list[dict], message: str, branch: str = "main", + ) -> dict: + """Commit a set of entry file ops (sidecar + body-only `.md`, from + `metadata.write_entry_files`) in one commit. Used by the direct-commit + metadata paths and, on a branch, by `open_entry_pr`.""" + return await self._gitea.change_files( + org, repo, files=files, + message=_stamp_single(message, actor), branch=branch, + author_name=actor.display_name, + author_email=actor.email or f"{actor.gitea_login}@users.noreply", + ) + + async def open_entry_pr( + self, actor: Actor, *, org: str, repo: str, slug: str, + files: list[dict], pr_title: str, pr_description: str, + branch_prefix: str = "metadata", + ) -> dict: + """Create a branch, commit entry file ops there, and open a PR — the + sidecar-aware successor to `open_metadata_pr`'s single-file write.""" + import secrets + branch = f"{branch_prefix}-{slug}-{secrets.token_hex(3)}" + await self._gitea.create_branch(org, repo, branch, from_branch="main") + await self.commit_entry_files( + actor, org=org, repo=repo, files=files, + message=pr_title, branch=branch) + _subject, pr_body = _stamp("", pr_description, actor) + pr = await self._gitea.create_pull( + org, repo, title=pr_title, body=pr_body, head=branch, base="main") + _log(actor, "open_entry_pr", rfc_slug=slug, branch_name=branch, + pr_number=pr["number"], details={"pr_title": pr_title}) + return pr +``` + +- [ ] **Step 4: Run to verify PASS** + +Run: `.venv/bin/pytest tests/test_bot_entry_files.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/bot.py backend/tests/test_bot_entry_files.py +git commit -m "feat(slice4): bot.commit_entry_files + open_entry_pr multi-file primitives (§22.4a)" +``` + +--- + +## Phase 3 — Make existing write paths sidecar-aware + +Goal for every task: a **migrated** entry (body-only `.md` + sidecar) must not +crash and must not re-grow frontmatter; a **legacy** entry keeps working. Each task +adds a regression test exercising a migrated entry through that path. + +### Task 3.1: `mark_entry_reviewed` (direct commit) sidecar-aware + +**Files:** +- Modify: `backend/app/bot.py:1189-1233` (`mark_entry_reviewed`) +- Test: `backend/tests/test_metadata_writepaths.py` *(new)* + +- [ ] **Step 1: Write failing test** + +Create `backend/tests/test_metadata_writepaths.py` with a shared migrated-entry +seed and the first case: + +```python +"""SLICE-4 — write paths are sidecar-aware: a migrated (body-only `.md` + +sidecar) entry never crashes `entry.parse` nor re-grows frontmatter.""" +from __future__ import annotations + +import asyncio + +from app import bot as bot_mod, gitea as gitea_mod, metadata +from app.bot import Actor +from app.config import load_config + +from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401 + +BODY_ONLY = "Alpha prose body.\n" +SIDECAR = ("slug: alpha\ntitle: Alpha\nstate: active\n" + "owners:\n- ben.stull\ntags:\n- one\npriority: P1\n") + + +def _seed_migrated(fake, repo="meta"): + fake.files[("wiggleverse", repo, "main", "rfcs/alpha.md")] = { + "content": BODY_ONLY, "sha": "m1"} + fake.files[("wiggleverse", repo, "main", "rfcs/alpha.meta.yaml")] = { + "content": SIDECAR, "sha": "m2"} + + +def _actor(): + return Actor(gitea_login="ben.stull", display_name="Ben", email="ben@x.io") + + +def test_mark_entry_reviewed_on_migrated_entry(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + _seed_migrated(fake) + cfg = load_config() + gitea = gitea_mod.Gitea(cfg) + bot = bot_mod.Bot(cfg, gitea) + # Must not raise (legacy code parsed body-only .md → ValueError). + asyncio.run(bot.mark_entry_reviewed( + _actor(), org="wiggleverse", meta_repo="meta", slug="alpha", + reviewed_by="ben.stull", reviewed_at="2026-06-07")) + md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] + sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"] + assert "---" not in md # body stays clean (no re-grown FM) + assert "reviewed_by: ben.stull" in sc # review stamp landed in the sidecar +``` + +- [ ] **Step 2: Run to verify FAIL** + +Run: `.venv/bin/pytest tests/test_metadata_writepaths.py::test_mark_entry_reviewed_on_migrated_entry -q` +Expected: FAIL (ValueError: Entry file missing frontmatter). + +- [ ] **Step 3: Implement** + +Rewrite `mark_entry_reviewed` (`backend/app/bot.py`) to dual-read and write via the +new primitive. Replace the `read_file → entry_mod.parse → serialize → update_file` +body with: + +```python + async def mark_entry_reviewed( + self, actor: Actor, *, org: str, meta_repo: str, slug: str, + reviewed_by: str, reviewed_at: str, + ) -> None: + md_path = f"rfcs/{slug}.md" + st = await metadata.read_entry_from_git(self._gitea, org, meta_repo, md_path) + if st is None: + return + e = metadata.apply_values(st.entry, { + "unreviewed": False, "reviewed_at": reviewed_at, "reviewed_by": reviewed_by, + }) + files = metadata.write_entry_files(md_path, e, st) + await self.commit_entry_files( + actor, org=org, repo=meta_repo, files=files, + message=f"Mark {slug} reviewed", branch="main") + _log(actor, "mark_entry_reviewed", rfc_slug=slug, + details={"reviewed_by": reviewed_by}) +``` + +Add `from . import metadata` to bot.py imports if not present. Note: `apply_values` +sets `unreviewed=False`; because `to_frontmatter_dict` omits `unreviewed` when +False, the merged dict will still carry it — guard by popping a False value. To +keep `metadata_dict` minimal, instead pass through `from_frontmatter` which reads +`unreviewed` truthily; `False`/absent are equivalent. Confirmed by the assertion +that the sidecar contains the review stamp. + +- [ ] **Step 4: Run to verify PASS** + +Run: `.venv/bin/pytest tests/test_metadata_writepaths.py::test_mark_entry_reviewed_on_migrated_entry -q` +Expected: PASS. Then run the existing bot/reviewed tests: +`.venv/bin/pytest tests/ -q -k "reviewed or bot"` — expect green. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/bot.py backend/tests/test_metadata_writepaths.py +git commit -m "fix(slice4): mark_entry_reviewed sidecar-aware (§22.4a write paths)" +``` + +### Task 3.2: `_extract_body` / `_wrap_body` (api_branches) sidecar-aware + +**Files:** +- Modify: `backend/app/api_branches.py:1165-1187` +- Test: `backend/tests/test_metadata_writepaths.py` + +A migrated `.md` is already body-only, so `_extract_body` should return it as-is +and `_wrap_body` should return the new body as-is (no parse/serialize). The +fix: dual-read tolerantly. + +- [ ] **Step 1: Write failing test** + +Add to `test_metadata_writepaths.py`: + +```python +def test_extract_wrap_body_on_body_only_md(): + from app import api_branches + rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"} + # Simulate the helper contract on a body-only file: extract == identity, + # wrap == identity (no frontmatter envelope to preserve). + body = api_branches._extract_body_pure(rfc, BODY_ONLY, "main", is_meta=True) + assert body == BODY_ONLY + wrapped = api_branches._wrap_body_pure(rfc, BODY_ONLY, "new body\n", "main", is_meta=True) + assert wrapped == "new body\n" # stays clean — no re-grown frontmatter +``` + +> Note: `_extract_body`/`_wrap_body` are closures inside `make_router`. Extract +> their logic into module-level pure helpers `_extract_body_pure` / +> `_wrap_body_pure(rfc, contents, new_body, branch, *, is_meta)` and have the +> closures delegate (passing `is_meta=_is_meta_target(rfc, branch)`), so they are +> unit-testable. This keeps the routing logic in the closure and the +> frontmatter logic pure. + +- [ ] **Step 2: Run to verify FAIL** + +Run: `.venv/bin/pytest tests/test_metadata_writepaths.py::test_extract_wrap_body_on_body_only_md -q` +Expected: FAIL (no `_extract_body_pure`). + +- [ ] **Step 3: Implement** + +Add module-level helpers in `backend/app/api_branches.py` (top of file, after +imports): + +```python +def _extract_body_pure(rfc, file_contents: str, branch: str, *, is_meta: bool) -> str: + """Editable body of an entry file. Meta-resident files carry a frontmatter + envelope (legacy) or are already body-only (migrated, §22.4a); per-RFC repo + files are body-only. Dual-read tolerant: a body-only `.md` returns as-is.""" + if not is_meta: + return file_contents + return metadata_mod.strip_frontmatter(file_contents) + + +def _wrap_body_pure(rfc, prior_contents: str, new_body: str, branch: str, *, is_meta: bool) -> str: + """Inverse of `_extract_body_pure`. Under §22.4a the body lives in the `.md` + and metadata in the sidecar, so wrapping is identity for both meta-resident + (body-only) and per-RFC files — frontmatter is never re-grown here. Metadata + edits go through the sidecar write path, not this body wrapper.""" + return new_body if new_body.endswith("\n") else new_body + "\n" +``` + +Add `from . import metadata as metadata_mod` to api_branches.py imports if absent. +Then change the closures to delegate: + +```python + def _extract_body(rfc, file_contents: str, branch: str = "main") -> str: + return _extract_body_pure( + rfc, file_contents, branch, is_meta=_is_meta_target(rfc, branch)) + + def _wrap_body(rfc, prior_contents: str, new_body: str, branch: str = "main") -> str: + return _wrap_body_pure( + rfc, prior_contents, new_body, branch, is_meta=_is_meta_target(rfc, branch)) +``` + +> **Behavioral note:** previously `_wrap_body` on a meta-resident legacy `.md` +> preserved frontmatter while swapping the body. Under the sidecar model the +> body-edit path writes only the body; legacy entries get lazily migrated to +> body-only on their next *metadata* edit. A legacy `.md` body edit now writes a +> body-only file — which is the intended clean-doc end state and is dual-read +> safe (its frontmatter, if any metadata is still there, would be in the sidecar +> only after a metadata edit; before that the entry has no sidecar and its +> metadata is the frontmatter). To avoid silently dropping un-migrated +> frontmatter on a pure body edit, keep legacy preservation: if the prior file +> still has frontmatter and there is no sidecar, preserve it. Implement that in +> `_wrap_body_pure`: + +```python +def _wrap_body_pure(rfc, prior_contents: str, new_body: str, branch: str, *, is_meta: bool) -> str: + nb = new_body if new_body.endswith("\n") else new_body + "\n" + if not is_meta: + return nb + # Legacy un-migrated meta file: preserve its frontmatter (metadata not yet + # in a sidecar). Migrated/body-only files stay clean. + if entry_mod.FRONTMATTER_RE.match(prior_contents): + e = entry_mod.parse(prior_contents) + e.body = nb + return entry_mod.serialize(e) + return nb +``` + +Update the test's `_wrap_body_pure` expectation accordingly: passing `BODY_ONLY` +(no frontmatter) as `prior_contents` yields the clean body — the assertion above +already reflects that. Add a second assertion for the legacy-preserve branch: + +```python +def test_wrap_body_preserves_legacy_frontmatter(): + from app import api_branches + legacy = "---\nslug: alpha\ntitle: Alpha\nstate: active\n---\n\nold body\n" + rfc = {"state": "super-draft", "repo": None, "slug": "alpha", "collection_id": "default"} + wrapped = api_branches._wrap_body_pure(rfc, legacy, "new body\n", "main", is_meta=True) + assert wrapped.startswith("---") # legacy frontmatter preserved + assert "new body" in wrapped +``` + +- [ ] **Step 4: Run to verify PASS** + +Run: `.venv/bin/pytest tests/test_metadata_writepaths.py -q -k "extract_wrap or wrap_body"` +Then: `.venv/bin/pytest tests/test_branch_path_routing.py tests/test_metadata_pr_merge.py -q` +Expected: PASS / green. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_branches.py backend/tests/test_metadata_writepaths.py +git commit -m "fix(slice4): body extract/wrap sidecar-aware, legacy frontmatter preserved (§22.4a)" +``` + +### Task 3.3: `_extract_body_for_replay` / `_wrap_body_for_replay` (api_prs) sidecar-aware + +**Files:** +- Modify: `backend/app/api_prs.py:1011-1025` +- Test: `backend/tests/test_metadata_writepaths.py` + +These mirror Task 3.2 for the PR-replay path. Apply the same pure-helper logic +(reuse the api_branches helpers or duplicate the small logic). + +- [ ] **Step 1: Write failing test** + +```python +def test_replay_wrappers_on_body_only(): + from app import api_prs + assert api_prs._extract_body_for_replay(True, BODY_ONLY) == BODY_ONLY + out = api_prs._wrap_body_for_replay(True, BODY_ONLY, "new\n") + assert out == "new\n" # clean, no re-grown frontmatter + legacy = "---\nslug: a\ntitle: A\nstate: active\n---\n\nold\n" + out2 = api_prs._wrap_body_for_replay(True, legacy, "new\n") + assert out2.startswith("---") # legacy preserved +``` + +- [ ] **Step 2: Run to verify FAIL** — Run the test; expect failure (ValueError on +body-only parse in current code). + +- [ ] **Step 3: Implement** — Rewrite the two helpers in `api_prs.py`: + +```python +def _extract_body_for_replay(is_super_draft: bool, content: str) -> str: + if not is_super_draft: + return content + return metadata_mod.strip_frontmatter(content) + + +def _wrap_body_for_replay(is_super_draft: bool, prior_content: str, new_body: str) -> str: + nb = new_body if new_body.endswith("\n") else new_body + "\n" + if not is_super_draft: + return nb + if entry_mod.FRONTMATTER_RE.match(prior_content): + e = entry_mod.parse(prior_content) + e.body = nb + return entry_mod.serialize(e) + return nb +``` + +Add `from . import metadata as metadata_mod` to api_prs.py if absent. + +- [ ] **Step 4: Run to verify PASS** — the new test + `.venv/bin/pytest tests/test_*pr*.py -q`. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_prs.py backend/tests/test_metadata_writepaths.py +git commit -m "fix(slice4): PR-replay body wrappers sidecar-aware (§22.4a)" +``` + +### Task 3.4: `_read_meta_entry`, `retire_rfc`, `unretire_rfc` (api_graduation) sidecar-aware + +**Files:** +- Modify: `backend/app/api_graduation.py` (`_read_meta_entry` ~605-615; retire/unretire 521-573; `_run_state_flip`) +- Test: `backend/tests/test_metadata_writepaths.py` + +- [ ] **Step 1: Write failing test** (uses the app client to retire a migrated active entry) + +```python +def test_retire_migrated_entry_does_not_crash(app_with_fake_gitea): + fake = app_with_fake_gitea.fake + client, login_owner = app_with_fake_gitea.client, app_with_fake_gitea.login_owner + _seed_migrated(fake) + # ingest so cached_rfcs has the entry + asyncio.run(app_with_fake_gitea.refresh()) + login_owner() + r = client.post("/api/rfcs/alpha/retire") + assert r.status_code in (200, 202) + # state flip recorded in the sidecar; .md stays clean + md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] + assert "---" not in md +``` + +> Adapt `app_with_fake_gitea` accessors to the fixture's real API (it exposes a +> TestClient + login helpers + a refresh hook — inspect `test_propose_vertical.py` +> and mirror an existing retire test, e.g. in `test_retired_state` tests). + +- [ ] **Step 2: Run to verify FAIL** — expect a 500 / ValueError from `_read_meta_entry`'s `entry_mod.parse`. + +- [ ] **Step 3: Implement** + +Rewrite `_read_meta_entry` to dual-read and return the git state, and update +retire/unretire to write via `write_entry_files` + `bot.commit_entry_files` +(retire is a direct state flip per the existing `_run_state_flip`): + +```python + async def _read_meta_entry(slug: str): + """Dual-read an entry from meta-main → EntryGitState (sidecar-aware).""" + org = config.gitea_org + repo = projects_mod.default_content_repo(config) or "" + return await metadata.read_entry_from_git(gitea, org, repo, f"rfcs/{slug}.md") +``` + +In `retire_rfc` / `unretire_rfc`, replace `entry = _read_meta_entry(...)`, +`entry.state = "retired"`, `entry_mod.serialize(entry)`, single-file write with: + +```python + st = await _read_meta_entry(slug) + if st is None: + raise HTTPException(409, f"Meta entry rfcs/{slug}.md not found on main") + e = metadata.apply_values(st.entry, {"state": "retired"}) # or "active" for unretire + files = metadata.write_entry_files(f"rfcs/{slug}.md", e, st) +``` + +Then route `files` through whatever `_run_state_flip` does — extend it to accept a +`files` list and call `bot.commit_entry_files(...)` (direct to main) instead of a +single `update_file`. Read `_run_state_flip` and adapt; keep its PR-vs-direct +behavior, just swap the file write for the multi-file commit. + +- [ ] **Step 4: Run to verify PASS** — new test + `.venv/bin/pytest tests/test_retired_state.py -q` (and any unretire tests). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_graduation.py backend/tests/test_metadata_writepaths.py +git commit -m "fix(slice4): _read_meta_entry + retire/unretire sidecar-aware (§22.4a)" +``` + +### Task 3.5: `graduate` + `claim_ownership` + `_orchestrate` sidecar-aware + +**Files:** +- Modify: `backend/app/api_graduation.py` (`graduate` 313-418; `claim_ownership` 461-509; `_orchestrate`); `backend/app/bot.py` (`open_graduation_pr`, `open_claim_pr`) +- Test: `backend/tests/test_graduation_vertical.py` (add a migrated-entry case) + +- [ ] **Step 1: Write failing test** + +Add to `test_graduation_vertical.py` a case that seeds a **migrated** super-draft +(body-only `.md` + sidecar with `state: super-draft`) and graduates it via the +existing `?_sync=1` seam, asserting no crash and that the graduation PR carries the +sidecar (state→active + stamps) with a clean body-only `.md`. Mirror the existing +graduation happy-path test's setup, swapping the seed to migrated form. + +- [ ] **Step 2: Run to verify FAIL** — expect failure at `entry_mod.parse(meta_text)` in `graduate`. + +- [ ] **Step 3: Implement** + +In `graduate`: replace `gitea.read_file(...) → entry_mod.parse` with +`st = await metadata.read_entry_from_git(gitea, org, repo, f"rfcs/{slug}.md")`; +build the graduated entry from `st.entry` (same field copy), then compute +`files = metadata.write_entry_files(f"rfcs/{slug}.md", graduated_entry, st)` and +thread `files` (instead of `graduated_contents` + `meta_file_sha`) into +`_orchestrate`. Update `_orchestrate` and `bot.open_graduation_pr` to accept +`files` and call `bot.open_entry_pr(..., files=files, branch_prefix="graduate")` +instead of the single-file `update_file`. Do the same shape change for +`claim_ownership` → `bot.open_claim_pr` (mutate `owners`, `branch_prefix="claim"`). + +Keep the graduation orchestrator's other steps (rfc_id, audit, SSE) unchanged — +only the file read + PR file write change. + +- [ ] **Step 4: Run to verify PASS** — new test + `.venv/bin/pytest tests/test_graduation_vertical.py -q`. Then run the full suite: `.venv/bin/pytest -q` (expect prior 615 + new tests green). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_graduation.py backend/app/bot.py backend/tests/test_graduation_vertical.py +git commit -m "fix(slice4): graduate + claim sidecar-aware via open_entry_pr (§22.4a)" +``` + +--- + +## Phase 4 — New `POST .../meta` endpoint + GET RFC exposure + +### Task 4.1: `api_metadata` router — `POST .../rfcs/{slug}/meta` + +**Files:** +- Create: `backend/app/api_metadata.py` +- Modify: `backend/app/api.py` (mount router, ~line 132) +- Test: `backend/tests/test_metadata_edit_endpoint.py` *(new)* + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_metadata_edit_endpoint.py`. Seed a collection with a +`fields:` schema (priority enum + tags) and a legacy entry; cover: (a) a +contributor sets `priority: P0` → 200, sidecar created, `.md` body-only +(lazy-migrate), cache `meta_json` reflects P0; (b) a value outside the enum → 422 +with the field problem; (c) an anonymous / non-contributor → 403; (d) an unknown +field → 422; (e) editing an already-migrated entry → 200, only the sidecar updates. + +```python +"""SLICE-4 — single-entry metadata edit endpoint (PUC-1).""" +from __future__ import annotations + +# Mirror the collection+schema seeding used in test_metadata_cache.py / +# the SLICE-3 facet tests; use the app client + login helpers from the +# propose-vertical fixtures. Assert: +# POST /api/projects//collections//rfcs//meta +# {"values": {"priority": "P0"}} → 200; sidecar written; cache updated. +# {"values": {"priority": "ZZZ"}} → 422 (not-in-values). +# {"values": {"nope": "x"}} → 422 (unknown field). +# anonymous → 403. +``` + +(Write the concrete cases following the seeding pattern in +`test_metadata_cache.py` and the SLICE-3 facet endpoint tests.) + +- [ ] **Step 2: Run to verify FAIL** — endpoint 404 (not mounted). + +- [ ] **Step 3: Implement** + +Create `backend/app/api_metadata.py`: + +```python +"""§22.4a SLICE-4/5 — entry metadata edit endpoints. + +`POST .../rfcs//meta` writes schema-defined metadata to an entry's sidecar +with a direct commit (D7: direct commit for authorized roles), validated against +the collection's field schema at the write boundary (INV-4), lazy-migrating a +legacy entry to a clean body-only `.md` on first edit. The Owner-gated +`metadata.migrate_collection` operator endpoint also lives here (SLICE-4 carried +work); SLICE-5's bulk endpoint will join it. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from . import (auth, cache, collections as collections_mod, + metadata as metadata_mod, metadata_schema, projects as projects_mod) +from .bot import Bot +from .config import Config +from .gitea import Gitea, GiteaError + + +class MetaEditBody(BaseModel): + values: dict[str, Any] + + +def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: + router = APIRouter() + + def _content_repo() -> tuple[str, str]: + return config.gitea_org, (projects_mod.default_content_repo(config) or "") + + @router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/meta") + async def edit_meta( + project_id: str, collection_id: str, slug: str, + body: MetaEditBody, request: Request, + ) -> dict[str, Any]: + viewer = auth.current_user(request) + if collections_mod.project_of_collection(collection_id) != project_id: + raise HTTPException(404, "Collection not in project") + # INV-4: contributor+ on the collection. + if not auth.can_contribute_in_collection(viewer, collection_id): + raise HTTPException(403, "Contributor access required to edit metadata") + col = collections_mod.get_collection(collection_id) + fields = (col or {}).get("fields") or {} + if not fields: + raise HTTPException(422, "Collection declares no editable fields") + if not body.values: + raise HTTPException(422, "Provide at least one field value") + unknown = [k for k in body.values if k not in fields] + if unknown: + raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}") + + org, repo = _content_repo() + md_path = f"rfcs/{slug}.md" + st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path) + if st is None: + raise HTTPException(404, f"{md_path} not found") + + new_entry = metadata_mod.apply_values(st.entry, body.values) + problems = metadata_schema.validate( + metadata_mod.metadata_dict(new_entry), fields) + if problems: + raise HTTPException(422, {"problems": [p.as_dict() for p in problems]}) + + files = metadata_mod.write_entry_files(md_path, new_entry, st) + try: + await bot.commit_entry_files( + viewer.as_actor(), org=org, repo=repo, files=files, + message=f"Edit metadata: {slug}", branch="main") + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + await cache.refresh_meta_repo(config, gitea) + return { + "ok": True, "slug": slug, + "meta": metadata_mod.metadata_dict(new_entry), + } + + return router +``` + +Mount in `backend/app/api.py` near the other routers (after `api_graduation`): + +```python + from . import api_metadata + router.include_router(api_metadata.make_router(config, gitea, bot)) +``` + +> Confirm `auth.can_contribute_in_collection` returns False for `viewer=None` +> (it does — checked at api_branches.py:1332 usage). `viewer.as_actor()` exists +> (used across graduation). `collections_mod.project_of_collection` and +> `get_collection` exist (used in api.py). + +- [ ] **Step 4: Run to verify PASS** — `.venv/bin/pytest tests/test_metadata_edit_endpoint.py -q`. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_metadata.py backend/app/api.py backend/tests/test_metadata_edit_endpoint.py +git commit -m "feat(slice4): POST .../meta single-entry metadata edit endpoint (§22.4a PUC-1)" +``` + +### Task 4.2: GET RFC exposes `meta` + `can_edit_meta` + +**Files:** +- Modify: `backend/app/api.py` (`_serialize_rfc` ~1374; `_get_rfc_for_collection` ~823) +- Test: `backend/tests/test_metadata_edit_endpoint.py` + +- [ ] **Step 1: Write failing test** + +```python +def test_get_rfc_exposes_meta_and_can_edit(app_with_fake_gitea): + # After seeding an entry with priority P1 in a fields-collection: + # GET .../collections//rfcs/ → + # payload["meta"]["priority"] == "P1" + # payload["can_edit_meta"] is True for a contributor, False for anon. + ... +``` + +- [ ] **Step 2: Run to verify FAIL** — KeyError `meta`. + +- [ ] **Step 3: Implement** + +In `_serialize_rfc`, add the metadata mapping from the cached `meta_json`: + +```python + "meta": json.loads(row["meta_json"] or "{}"), +``` + +In `_get_rfc_for_collection`, after building `payload`, add the capability: + +```python + payload["can_edit_meta"] = bool( + auth.can_contribute_in_collection(viewer, collection_id) + ) +``` + +- [ ] **Step 4: Run to verify PASS** — the test + `.venv/bin/pytest tests/test_metadata_cache.py -q` (ensure `_serialize_rfc` consumers still pass). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api.py backend/tests/test_metadata_edit_endpoint.py +git commit -m "feat(slice4): GET RFC exposes meta values + can_edit_meta (§22.4a)" +``` + +--- + +## Phase 5 — Owner-gated `migrate_collection` operator endpoint + +Now that write paths are sidecar-aware, the migrate trigger is safe to ship. + +### Task 5.1: `POST .../collections/{collection_id}/migrate` + +**Files:** +- Modify: `backend/app/api_metadata.py` +- Test: `backend/tests/test_metadata_migration.py` (add endpoint cases) + +- [ ] **Step 1: Write failing test** + +Add to `test_metadata_migration.py`: an Owner POSTs the migrate endpoint and the +collection's legacy entries become sidecar+body-only (reuse the existing +`migrate_collection` assertions, driven through the route); a non-Owner → 403; a +second call → `{"committed": false}` (idempotent). + +- [ ] **Step 2: Run to verify FAIL** — 404 (route absent). + +- [ ] **Step 3: Implement** — add to `api_metadata.make_router`: + +```python + @router.post("/api/projects/{project_id}/collections/{collection_id}/migrate") + async def migrate(project_id: str, collection_id: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + if collections_mod.project_of_collection(collection_id) != project_id: + raise HTTPException(404, "Collection not in project") + # Owner-gated (operator action): collection superuser only. + if not auth.is_collection_superuser(viewer, collection_id): + raise HTTPException(403, "Owner access required to migrate a collection") + org, repo = _content_repo() + subfolder = collections_mod.subfolder_of_collection(collection_id) or "" + try: + result = await metadata_mod.migrate_collection( + gitea, org=org, repo=repo, subfolder=subfolder, + actor=viewer.as_actor()) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + if result["committed"]: + await cache.refresh_meta_repo(config, gitea) + return result +``` + +> Verify the collection→subfolder accessor name (`subfolder_of_collection` or +> similar) in `collections.py`; use the real one. `migrate_collection` already +> exists in `metadata.py`. + +- [ ] **Step 4: Run to verify PASS** — `.venv/bin/pytest tests/test_metadata_migration.py -q`. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_metadata.py backend/tests/test_metadata_migration.py +git commit -m "feat(slice4): Owner-gated collection migrate endpoint (§22.4a PUC-5)" +``` + +--- + +## Phase 6 — Frontend metadata edit panel + +### Task 6.1: `saveEntryMeta` API client + +**Files:** +- Modify: `frontend/src/api.js` +- Test: covered via the component test (6.2) + +- [ ] **Step 1: Implement** (small, no separate unit test) + +```js +export async function saveEntryMeta(projectId, collectionId, slug, values) { + const res = await fetch( + `/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/meta`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values }), + }, + ) + return jsonOrThrow(res) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/api.js +git commit -m "feat(slice4): saveEntryMeta API client (§22.4a)" +``` + +### Task 6.2: `MetadataFieldsPanel` component + test + +**Files:** +- Create: `frontend/src/components/MetadataFieldsPanel.jsx` +- Create: `frontend/src/components/MetadataFieldsPanel.test.jsx` + +- [ ] **Step 1: Write failing test** + +`frontend/src/components/MetadataFieldsPanel.test.jsx`: + +```jsx +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +const saveEntryMeta = vi.fn() +vi.mock('../api', () => ({ saveEntryMeta: (...a) => saveEntryMeta(...a) })) +import MetadataFieldsPanel from './MetadataFieldsPanel.jsx' + +const fields = { + priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' }, + tags: { type: 'tags', label: 'Tags' }, + owner: { type: 'text', label: 'Owner' }, +} + +describe('MetadataFieldsPanel', () => { + beforeEach(() => saveEntryMeta.mockReset()) + + it('renders one control per schema field with current values (read-only)', () => { + render() + expect(screen.getByText('Priority')).toBeInTheDocument() + expect(screen.getByText('P1')).toBeInTheDocument() + expect(screen.getByText('x')).toBeInTheDocument() + // read-only: no select control + expect(screen.queryByRole('combobox')).toBeNull() + }) + + it('edits an enum and saves', async () => { + saveEntryMeta.mockResolvedValue({ ok: true, meta: { priority: 'P0' } }) + render() + fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'P0' } }) + fireEvent.click(screen.getByText('Save')) + await waitFor(() => expect(saveEntryMeta).toHaveBeenCalledWith( + 'ohm', 'bdd', 'a', { priority: 'P0' })) + }) + + it('shows a validation error from a 422', async () => { + saveEntryMeta.mockRejectedValue(new Error('priority not allowed')) + render() + fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'P0' } }) + fireEvent.click(screen.getByText('Save')) + await waitFor(() => expect(screen.getByText(/not allowed/)).toBeInTheDocument()) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** + +Run: `cd frontend && npm test -- --run src/components/MetadataFieldsPanel.test.jsx` +Expected: FAIL (component missing). + +- [ ] **Step 3: Implement** + +`frontend/src/components/MetadataFieldsPanel.jsx`: + +```jsx +import React, { useState } from 'react' +import { saveEntryMeta } from '../api' + +const labelFor = (name, def) => + def?.label || name.charAt(0).toUpperCase() + name.slice(1) + +export default function MetadataFieldsPanel({ + projectId, collectionId, slug, fields, meta, canEdit, +}) { + const [draft, setDraft] = useState(() => ({ ...(meta || {}) })) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [tagInput, setTagInput] = useState('') + + if (!fields || Object.keys(fields).length === 0) return null + + const setField = (name, value) => + setDraft(d => ({ ...d, [name]: value })) + + const changed = Object.keys(fields).some( + n => JSON.stringify(draft[n] ?? null) !== JSON.stringify((meta || {})[n] ?? null)) + + const onSave = async () => { + setSaving(true); setError(null) + const values = {} + for (const n of Object.keys(fields)) { + if (JSON.stringify(draft[n] ?? null) !== JSON.stringify((meta || {})[n] ?? null)) { + values[n] = draft[n] + } + } + try { + await saveEntryMeta(projectId, collectionId, slug, values) + } catch (e) { + setError(e.message || 'Could not save metadata') + } finally { + setSaving(false) + } + } + + return ( +
+ {Object.entries(fields).map(([name, def]) => ( +
+ + {!canEdit ? ( + + ) : def.type === 'enum' ? ( + + ) : def.type === 'tags' ? ( +
+ {(draft[name] || []).map(t => ( + + {t} + + + ))} + setTagInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && tagInput.trim()) { + e.preventDefault() + const cur = draft[name] || [] + if (!cur.includes(tagInput.trim())) setField(name, [...cur, tagInput.trim()]) + setTagInput('') + } + }} placeholder="add tag…" /> +
+ ) : ( + setField(name, e.target.value || null)} /> + )} +
+ ))} + {canEdit && ( +
+ + {error && {error}} +
+ )} +
+ ) +} + +function ReadOnlyValue({ type, value }) { + if (type === 'tags') { + return {(value || []).map(t => {t})} + } + return {value ?? '—'} +} +``` + +- [ ] **Step 4: Run to verify PASS** + +Run: `cd frontend && npm test -- --run src/components/MetadataFieldsPanel.test.jsx` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/MetadataFieldsPanel.jsx frontend/src/components/MetadataFieldsPanel.test.jsx +git commit -m "feat(slice4): MetadataFieldsPanel schema-driven view/edit panel (§22.4a PUC-1)" +``` + +### Task 6.3: Wire the panel into `RFCView` + +**Files:** +- Modify: `frontend/src/components/RFCView.jsx` +- Test: extend `MetadataFieldsPanel.test.jsx` coverage is sufficient; add a light + RFCView render assertion only if an existing RFCView test harness exists. + +- [ ] **Step 1: Implement** + +In `RFCView.jsx`: import `getCollection` and `MetadataFieldsPanel`; fetch the +collection's `fields` (once, by project+collection from the route) into state; +render `` in the entry header region (above the prose body, +per UX §5.2). Gate render on `fields` being non-empty (INV-5: a no-fields +collection shows nothing new — preserves today's behavior). + +```jsx +// near other imports +import { getCollection } from '../api' +import MetadataFieldsPanel from './MetadataFieldsPanel.jsx' + +// in the component, after entry is loaded: +const [fields, setFields] = useState(null) +useEffect(() => { + if (!pid || !cid) return + getCollection(pid, cid).then(c => setFields(c?.fields || null)).catch(() => setFields(null)) +}, [pid, cid]) + +// in the render, above the body: +{fields && entry && ( + +)} +``` + +> Use the project/collection id hooks already used in RFCView (`useProjectId` / +> `useCollectionId` from `lib/entryPaths`, per the Catalog pattern). Confirm the +> exact entry state variable name in RFCView (`entry`). + +- [ ] **Step 2: Run frontend suite** + +Run: `cd frontend && npm test -- --run` +Expected: all green (existing + new). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/RFCView.jsx +git commit -m "feat(slice4): render MetadataFieldsPanel in RFCView detail (§22.4a §5.2)" +``` + +--- + +## Phase 7 — Release + +### Task 7.1: Version bump + changelog + SPEC pointer + +**Files:** +- Modify: `VERSION`, `frontend/package.json`, `CHANGELOG.md`, `SPEC.md` + +- [ ] **Step 1: Bump version** — `VERSION` → `0.50.0`; `frontend/package.json` + `"version": "0.50.0"` (must mirror, per §20). + +- [ ] **Step 2: CHANGELOG** — add a `0.50.0` (minor) entry: new single-entry + metadata edit endpoint + detail panel (PUC-1); write paths made sidecar-aware; + Owner-gated collection-migrate endpoint shipped (PUC-5). Upgrade steps: + none required for a no-`fields:` collection (INV-5); to adopt, declare a + `fields:` block and optionally run the migrate endpoint. + +- [ ] **Step 3: SPEC pointer** — in `SPEC.md` §22.4a (and §9.5 cross-ref) note + that the entry metadata edit (`POST .../meta`, direct-commit to sidecar) and the + Owner-gated collection migration endpoint are shipped in SLICE-4; entry writes + are sidecar-aware. Keep it a thin pointer to the design doc. + +- [ ] **Step 4: Full test sweep** + +Run: `cd backend && .venv/bin/pytest -q` (expect ≥615 + new green) +Run: `cd frontend && npm test -- --run` (expect green) + +- [ ] **Step 5: Commit + PR** + +```bash +git add VERSION frontend/package.json CHANGELOG.md SPEC.md +git commit -m "release(slice4): v0.50.0 — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4)" +git push -u origin worktree-metadata-slice4 +``` + +Then open a PR on `origin` (git.wiggleverse.org, Gitea API) titled +`v0.50.0 — SLICE-4: single-entry metadata edit (§22.4a)`, body summarizing the +DoD, and merge to `main` per the autonomous flow. + +--- + +## Self-Review notes (gaps to watch during execution) + +1. **`apply_values` + `unreviewed=False`** — `to_frontmatter_dict` omits falsey + `unreviewed`, so a `{"unreviewed": False}` merge is harmless; verify the + reviewed-sidecar assertion in Task 3.1. +2. **Fixture API** — `app_with_fake_gitea` exact accessors (client/login/refresh) + must be read from `test_propose_vertical.py`; the test snippets above name + `.client`, `.fake`, `.login_owner`, `.refresh` illustratively — match reality. +3. **`_run_state_flip`** (Task 3.4) — read it before editing; it may open a PR for + retire rather than a direct commit. Preserve its governance; only swap the file + write to the multi-file commit. +4. **`collections.py` accessors** — confirm `project_of_collection`, `get_collection`, + `subfolder_of_collection` (or the real subfolder getter) names before use. +5. **E2E**: a faceted/edit Playwright path is deferred consistent with SLICE-3 + (no faceted-collection seeding fixture; §9 references E2E, doesn't yet automate + it). Covered by API integration + Vitest. **Log this deferral** in the PR + memory. +6. **`viewer` None handling** in the new endpoint — `auth.can_contribute_in_collection(None, …)` + must be False (→ 403); confirm and keep the anonymous test.