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>
56 KiB
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
.mdto body-only, in onechange_filescommit." Direct-commit paths (mark_entry_reviewed, the newPOST .../meta) commit tomain; PR paths (graduate, claim, the existing/metadataedit) 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 aCo-Authored-Bytrailer.
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 andEntry, sidecar-first.backend/app/api_metadata.py(new module) — thePOST .../metaedit endpoint and the Owner-gatedPOST .../migrateoperator endpoint (amake_routermounted inapi.py). SLICE-5's bulk endpoint will land here later.
Backend — modified:
backend/app/bot.py— addcommit_entry_files(multi-file commit to a branch or main) +open_entry_pr(branch + commit + PR); makemark_entry_reviewedsidecar-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_rfcaddsmeta;_get_rfc_for_collectionaddscan_edit_meta; mountapi_metadatarouter.
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 collectionfields, 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:
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_bodyare closures insidemake_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 (passingis_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_bodyon a meta-resident legacy.mdpreserved 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.mdbody 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_giteaaccessors to the fixture's real API (it exposes a TestClient + login helpers + a refresh hook — inspecttest_propose_vertical.pyand mirror an existing retire test, e.g. intest_retired_statetests).
-
Step 2: Run to verify FAIL — expect a 500 / ValueError from
_read_meta_entry'sentry_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(graduate313-418;claim_ownership461-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)ingraduate. -
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
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_collectionreturns False forviewer=None(it does — checked at api_branches.py:1332 usage).viewer.as_actor()exists (used across graduation).collections_mod.project_of_collectionandget_collectionexist (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_rfcconsumers 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_collectionor similar) incollections.py; use the real one.migrate_collectionalready exists inmetadata.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.jsxcoverage 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/useCollectionIdfromlib/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 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 afields: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)
apply_values+unreviewed=False—to_frontmatter_dictomits falseyunreviewed, so a{"unreviewed": False}merge is harmless; verify the reviewed-sidecar assertion in Task 3.1.- Fixture API —
app_with_fake_giteaexact accessors (client/login/refresh) must be read fromtest_propose_vertical.py; the test snippets above name.client,.fake,.login_owner,.refreshillustratively — match reality. _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.collections.pyaccessors — confirmproject_of_collection,get_collection,subfolder_of_collection(or the real subfolder getter) names before use.- 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.
viewerNone handling in the new endpoint —auth.can_contribute_in_collection(None, …)must be False (→ 403); confirm and keep the anonymous test.