49981e2d6e
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>
1483 lines
56 KiB
Markdown
1483 lines
56 KiB
Markdown
# 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.
|