# 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.