# 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 `<slug>.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/<file> -q` (the suite
  is 615 green at baseline). Run the whole suite at phase boundaries.
- Run frontend tests from `frontend/`: `npm test -- --run <file>` (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 `<dir>/<slug>.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/<pid>/collections/<cid>/rfcs/<slug>/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/<slug>/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/<cid>/rfcs/<slug> →
    #     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(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
      fields={fields} meta={{ priority: 'P1', tags: ['x'], owner: 'sam' }}
      canEdit={false} />)
    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(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
      fields={fields} meta={{ priority: 'P1' }} canEdit />)
    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(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
      fields={fields} meta={{ priority: 'P1' }} canEdit />)
    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 (
    <div className="metadata-fields-panel">
      {Object.entries(fields).map(([name, def]) => (
        <div key={name} className="metadata-field">
          <label htmlFor={`mf-${name}`}>{labelFor(name, def)}</label>
          {!canEdit ? (
            <ReadOnlyValue type={def.type} value={draft[name]} />
          ) : def.type === 'enum' ? (
            <select id={`mf-${name}`} value={draft[name] ?? ''}
              onChange={e => setField(name, e.target.value || null)}>
              <option value="">—</option>
              {(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
            </select>
          ) : def.type === 'tags' ? (
            <div className="tags-edit">
              {(draft[name] || []).map(t => (
                <span key={t} className="tag-chip">
                  {t}
                  <button type="button" aria-label={`remove ${t}`}
                    onClick={() => setField(name, (draft[name] || []).filter(x => x !== t))}>×</button>
                </span>
              ))}
              <input id={`mf-${name}`} value={tagInput}
                onChange={e => 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…" />
            </div>
          ) : (
            <input id={`mf-${name}`} type="text" value={draft[name] ?? ''}
              onChange={e => setField(name, e.target.value || null)} />
          )}
        </div>
      ))}
      {canEdit && (
        <div className="metadata-fields-actions">
          <button type="button" onClick={onSave} disabled={saving || !changed}>
            {saving ? 'Saving…' : 'Save'}
          </button>
          {error && <span className="field-error">{error}</span>}
        </div>
      )}
    </div>
  )
}

function ReadOnlyValue({ type, value }) {
  if (type === 'tags') {
    return <span>{(value || []).map(t => <span key={t} className="tag-chip">{t}</span>)}</span>
  }
  return <span>{value ?? '—'}</span>
}
```

- [ ] **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 `<MetadataFieldsPanel ... fields={fields} meta={entry.meta}
canEdit={entry.can_edit_meta} />` 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 && (
  <MetadataFieldsPanel projectId={pid} collectionId={cid} slug={slug}
    fields={fields} meta={entry.meta || {}} canEdit={!!entry.can_edit_meta} />
)}
```

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