Files
rfc-app/docs/superpowers/plans/2026-06-07-slice4-single-entry-metadata-edit.md
T
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

1483 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.