Files
rfc-app/docs/superpowers/plans/2026-06-07-slice4-single-entry-metadata-edit.md
Ben Stull 49981e2d6e feat(slice4): metadata sidecar git read/write helpers — dual-read + lazy-migrate ops (§22.4a)
apply_values, EntryGitState, read_entry_from_git, write_entry_files, sidecar_path_for.
Plus the SLICE-4 implementation plan.

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

56 KiB
Raw Permalink Blame History

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.pygraduate, 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.jssaveEntryMeta(...).
  • frontend/src/components/RFCView.jsx — fetch collection fields, render the panel.

Docs:

  • VERSION, frontend/package.json0.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:

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:

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:

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

"""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):

@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
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:

"""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:

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

"""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:

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

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):

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:

    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:

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:

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

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

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)

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):

    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:

        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

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

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.

"""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:

"""§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):

    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

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

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:

        "meta": json.loads(row["meta_json"] or "{}"),

In _get_rfc_for_collection, after building payload, add the capability:

        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

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:

    @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

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)

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

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:

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

// 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
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 versionVERSION0.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
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=Falseto_frontmatter_dict omits falsey unreviewed, so a {"unreviewed": False} merge is harmless; verify the reviewed-sidecar assertion in Task 3.1.
  2. Fixture APIapp_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.