v0.47.0 — SLICE-1: metadata sidecars — storage + dual-read + migration tool

§22.4a SLICE-1 of docs/design/2026-06-06-configurable-collection-metadata.md
(§7.2). Entry metadata can live in a per-entry `<slug>.meta.yaml` sidecar with
the `.md` kept as pure prose (INV-2). Additive and non-breaking — with no
sidecars present every corpus stays on the legacy frontmatter path,
byte-identical (N=1 unchanged).

- Dual-read (app/metadata.py `read_entry`) — sidecar-else-legacy-frontmatter,
  identical records (INV-6); unknown/forward-compat keys ride along through
  parse→serialize and migration (INV-7, `Entry.extra`). A degenerate sidecar
  (malformed/empty/slug-less) never drops the entry — slug backstopped from the
  filename stem, flagged not lost (INV-3).
- Migration tool (`metadata.migrate_collection`) — idempotent, one ChangeFiles
  commit per collection (new `gitea.change_files`). Tested as a function; its
  Owner-gated operator trigger is DEFERRED to SLICE-4 (write paths must become
  sidecar-aware first — see the design's SLICE-4 note + INV-8). No production
  trigger ships here, so no corpus is rewritten.
- Malformed flag — migration 033 adds `cached_rfcs.metadata_malformed`
  (additive); the corpus mirror derives it; catalog list + entry-detail APIs
  surface `metadata_malformed`.
- INV-7 at graduation — graduation now carries `Entry.extra` through the rebuild
  instead of dropping forward-compat keys.

Gate: backend 575 passed (28 new: test_metadata / _migration / _cache +
graduation extra-preservation). Frontend untouched. CHANGELOG 0.47.0 +
upgrade-steps; VERSION + frontend/package.json -> 0.47.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 08:24:27 -07:00
parent b1acc2382d
commit f05ee59763
17 changed files with 1025 additions and 11 deletions
+28
View File
@@ -224,6 +224,34 @@ def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
assert gone not in kinds, f"retired audit row present: {gone}"
def test_graduate_preserves_unknown_frontmatter_keys(app_with_fake_gitea):
"""§22.4a INV-7: a forward-compat / unknown frontmatter key on the
super-draft entry must ride through the graduation rebuild, not be dropped."""
from fastapi.testclient import TestClient
from app import entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH,
owners=["ben"], arbiters=["ben"])
# Inject an unknown key into the seeded entry's frontmatter.
key = ("wiggleverse", "meta", "main", "rfcs/ohm.md")
e = entry_mod.parse(fake.files[key]["content"])
e.extra["priority"] = "P1"
fake.files[key]["content"] = entry_mod.serialize(e)
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test")
r = client.post("/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "owners": ["ben"]})
assert r.status_code == 200, r.text
graduated = entry_mod.parse(fake.files[key]["content"])
assert graduated.state == "active"
assert graduated.extra.get("priority") == "P1"
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation — the body is kept, so they coexist. /check stays
+199
View File
@@ -0,0 +1,199 @@
"""SLICE-1 unit tests — sidecar metadata: dual-read, unknown-key preservation,
frontmatter stripping, malformed detection.
Pure functions only (no DB / no Gitea). Per
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-1) and
INV-6 (dual-read), INV-7 (unknown keys ride along), INV-2 (clean body).
"""
from __future__ import annotations
import yaml
from app import entry as entry_mod
from app import metadata
LEGACY_MD = """---
slug: view-metrics
title: View today's metrics
state: active
owners:
- ben.stull
tags:
- dashboard
- analytics
priority: P1
owner: hasan
---
This is the prose body.
Second paragraph.
"""
# ---- INV-7: unknown keys ride along ----
def test_parse_preserves_unknown_keys_in_extra():
e = entry_mod.parse(LEGACY_MD)
assert e.extra == {"priority": "P1", "owner": "hasan"}
def test_serialize_round_trip_preserves_unknown_keys():
e = entry_mod.parse(LEGACY_MD)
text = entry_mod.serialize(e)
e2 = entry_mod.parse(text)
assert e2.extra == {"priority": "P1", "owner": "hasan"}
assert e2.tags == ["dashboard", "analytics"]
assert e2.owners == ["ben.stull"]
def test_known_keys_never_leak_into_extra():
e = entry_mod.parse(LEGACY_MD)
for known in ("slug", "title", "state", "owners", "tags"):
assert known not in e.extra
# ---- metadata_dict / sidecar_yaml ----
def test_metadata_dict_merges_known_and_extra():
e = entry_mod.parse(LEGACY_MD)
d = metadata.metadata_dict(e)
assert d["slug"] == "view-metrics"
assert d["title"] == "View today's metrics"
assert d["state"] == "active"
assert d["tags"] == ["dashboard", "analytics"]
# forward-compat keys present
assert d["priority"] == "P1"
assert d["owner"] == "hasan"
def test_sidecar_yaml_is_parseable_and_has_no_frontmatter_fences():
e = entry_mod.parse(LEGACY_MD)
sc = metadata.sidecar_yaml(e)
assert "---" not in sc.splitlines()[0]
loaded = yaml.safe_load(sc)
assert loaded["slug"] == "view-metrics"
assert loaded["priority"] == "P1"
# ---- strip_frontmatter (INV-2) ----
def test_strip_frontmatter_removes_leading_block():
body = metadata.strip_frontmatter(LEGACY_MD)
assert body.startswith("This is the prose body.")
assert "slug:" not in body
assert "priority:" not in body
def test_strip_frontmatter_passthrough_when_no_frontmatter():
plain = "Just a body.\n\nNo frontmatter here.\n"
assert metadata.strip_frontmatter(plain).strip() == plain.strip()
# ---- parse_sidecar (malformed detection, INV-3) ----
def test_parse_sidecar_good():
values, malformed = metadata.parse_sidecar("slug: a\ntitle: A\npriority: P0\n")
assert malformed is False
assert values == {"slug": "a", "title": "A", "priority": "P0"}
def test_parse_sidecar_non_mapping_is_malformed():
values, malformed = metadata.parse_sidecar("- just\n- a\n- list\n")
assert malformed is True
assert values == {}
def test_parse_sidecar_invalid_yaml_is_malformed():
values, malformed = metadata.parse_sidecar("slug: : : not yaml\n bad: [unclosed\n")
assert malformed is True
assert values == {}
def test_parse_sidecar_empty_is_empty_not_malformed():
values, malformed = metadata.parse_sidecar("")
assert malformed is False
assert values == {}
# ---- read_entry dual-read equivalence (INV-6) ----
def test_dual_read_sidecar_matches_legacy():
legacy_entry, legacy_bad = metadata.read_entry(LEGACY_MD, None)
# The migrated form: body-only .md + a sidecar holding the metadata.
migrated_md = metadata.strip_frontmatter(LEGACY_MD)
sidecar_text = metadata.sidecar_yaml(legacy_entry)
sidecar_entry, sidecar_bad = metadata.read_entry(migrated_md, sidecar_text)
assert legacy_bad is False
assert sidecar_bad is False
# Identical resulting records (INV-6).
assert sidecar_entry.slug == legacy_entry.slug
assert sidecar_entry.title == legacy_entry.title
assert sidecar_entry.state == legacy_entry.state
assert sidecar_entry.owners == legacy_entry.owners
assert sidecar_entry.tags == legacy_entry.tags
assert sidecar_entry.extra == legacy_entry.extra
assert sidecar_entry.body.strip() == legacy_entry.body.strip()
def test_read_entry_sidecar_takes_precedence_over_md_frontmatter():
# A not-yet-migrated .md still carrying frontmatter, plus a sidecar that
# disagrees: the sidecar wins for metadata; the body comes from the .md.
md_with_fm = "---\nslug: old\ntitle: Old Title\nstate: super-draft\n---\n\nBody.\n"
sidecar = "slug: new\ntitle: New Title\nstate: active\n"
e, malformed = metadata.read_entry(md_with_fm, sidecar)
assert malformed is False
assert e.title == "New Title"
assert e.state == "active"
assert e.body.strip() == "Body."
def test_read_entry_malformed_sidecar_still_loads_entry():
# INV-3: a malformed sidecar never hard-fails the read. The entry loads
# (from the .md frontmatter if present) and is flagged malformed.
md = "---\nslug: x\ntitle: X\nstate: active\n---\n\nBody.\n"
e, malformed = metadata.read_entry(md, "- not a mapping\n")
assert malformed is True
assert e.slug == "x"
assert e.title == "X"
assert e.body.strip() == "Body."
# ---- dual-read robustness: degenerate sidecars never drop the entry ----
def test_empty_sidecar_falls_back_to_md_frontmatter():
# An empty sidecar has no metadata to override with — keep the .md's.
md = "---\nslug: keep\ntitle: Keep Me\nstate: active\n---\n\nBody.\n"
e, malformed = metadata.read_entry(md, "", fallback_slug="keep")
assert malformed is False
assert e.slug == "keep"
assert e.title == "Keep Me"
def test_malformed_sidecar_on_body_only_md_loads_with_fallback_slug():
# The .md is already body-only (migrated) and the sidecar is corrupt:
# the entry must still load (INV-3), taking its slug from the filename stem.
e, malformed = metadata.read_entry("Just a body.\n", "- a\n- list\n", fallback_slug="foo")
assert malformed is True
assert e.slug == "foo"
def test_slugless_sidecar_uses_fallback_slug():
md = "Body only.\n"
sidecar = "title: No Slug Here\nstate: active\n"
e, malformed = metadata.read_entry(md, sidecar, fallback_slug="bar")
assert malformed is False
assert e.slug == "bar"
assert e.title == "No Slug Here"
# ---- sidecar filename helpers ----
def test_sidecar_filename_helpers():
assert metadata.sidecar_name("view-metrics") == "view-metrics.meta.yaml"
assert metadata.is_sidecar("view-metrics.meta.yaml") is True
assert metadata.is_sidecar("view-metrics.md") is False
assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics"
+121
View File
@@ -0,0 +1,121 @@
"""SLICE-1 integration — the corpus mirror reads sidecars (dual-read) and
derives the malformed flag (PUC-6, INV-3/INV-6).
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.2-6.3.
"""
from __future__ import annotations
import asyncio
from fastapi.testclient import TestClient
from app import cache, db, gitea as gitea_mod
from app.config import load_config
from test_propose_vertical import ( # noqa: F401 (fixtures)
app_with_fake_gitea,
tmp_env,
)
def _refresh():
cfg = load_config()
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
def _row(slug):
return db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
def test_mirror_reads_metadata_from_sidecar(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
# A migrated entry: body-only .md + a sidecar holding the metadata.
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.md")] = {
"content": "Just the prose body.\n", "sha": "s1"}
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.meta.yaml")] = {
"content": "slug: sidecar-one\ntitle: From Sidecar\nstate: active\ntags:\n- alpha\n",
"sha": "m1"}
_refresh()
row = _row("sidecar-one")
assert row is not None
assert row["title"] == "From Sidecar"
assert row["state"] == "active"
assert row["body"].strip() == "Just the prose body."
assert row["metadata_malformed"] == 0
def test_malformed_sidecar_flags_but_still_loads(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
# .md still has frontmatter; sidecar is malformed (a list, not a map).
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.md")] = {
"content": "---\nslug: bad-meta\ntitle: Legacy Title\nstate: active\n---\n\nBody.\n",
"sha": "b1"}
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.meta.yaml")] = {
"content": "- not\n- a\n- mapping\n", "sha": "b2"}
_refresh()
row = _row("bad-meta")
assert row is not None # INV-3: still loads
assert row["metadata_malformed"] == 1
# Falls back to the legacy .md frontmatter for the metadata.
assert row["title"] == "Legacy Title"
def test_malformed_flag_surfaces_in_catalog_api(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.md")] = {
"content": "---\nslug: flagged\ntitle: Flagged\nstate: active\n---\n\nB.\n",
"sha": "f1"}
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.meta.yaml")] = {
"content": "just a scalar\n", "sha": "f2"}
fake.files[("wiggleverse", "meta", "main", "rfcs/clean.md")] = {
"content": "---\nslug: clean\ntitle: Clean\nstate: active\n---\n\nB.\n",
"sha": "c1"}
_refresh()
items = {i["slug"]: i for i in client.get("/api/rfcs").json()["items"]}
assert items["flagged"]["metadata_malformed"] is True
assert items["clean"]["metadata_malformed"] is False
# And on the detail view.
assert client.get("/api/rfcs/flagged").json()["metadata_malformed"] is True
def test_malformed_sidecar_on_migrated_entry_still_loads_flagged(app_with_fake_gitea):
# INV-3 regression: a migrated (body-only .md) entry whose sidecar is
# corrupt must NOT vanish from the catalog — it loads (slug from the
# filename stem) and is flagged malformed.
app, fake = app_with_fake_gitea
with TestClient(app):
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.md")] = {
"content": "Just the body, no frontmatter.\n", "sha": "o1"}
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.meta.yaml")] = {
"content": "- corrupt\n- list\n", "sha": "o2"}
_refresh()
row = _row("orphaned")
assert row is not None # did not vanish
assert row["metadata_malformed"] == 1
assert row["body"].strip() == "Just the body, no frontmatter."
def test_legacy_collection_without_sidecars_unchanged(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app):
fake.files[("wiggleverse", "meta", "main", "rfcs/legacy.md")] = {
"content": "---\nslug: legacy\ntitle: Legacy\nstate: super-draft\n---\n\nPitch.\n",
"sha": "l1"}
_refresh()
row = _row("legacy")
assert row is not None
assert row["title"] == "Legacy"
assert row["state"] == "super-draft"
assert row["body"].strip() == "Pitch."
assert row["metadata_malformed"] == 0
+135
View File
@@ -0,0 +1,135 @@
"""SLICE-1 integration — frontmatter→sidecar migration tool (PUC-5).
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.5 / §7.2:
a tool walks a collection; for each entry with legacy frontmatter it writes
`<slug>.meta.yaml` and rewrites `<slug>.md` to the body only one commit per
collection, idempotent, preserving unknown keys (INV-7).
"""
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 ( # noqa: F401 (fixtures)
app_with_fake_gitea,
tmp_env,
)
ALPHA_MD = """---
slug: alpha
title: Alpha
state: active
owners:
- ben.stull
tags:
- one
priority: P1
---
Alpha body prose.
"""
BETA_MD = """---
slug: beta
title: Beta
state: super-draft
---
Beta body prose.
"""
def _seed_entries(fake):
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
"content": ALPHA_MD, "sha": "a0001"}
fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = {
"content": BETA_MD, "sha": "b0001"}
def _run_migration(subfolder=""):
cfg = load_config()
gitea = gitea_mod.Gitea(cfg)
return asyncio.run(
metadata.migrate_collection(
gitea, org="wiggleverse", repo="meta", subfolder=subfolder
)
)
def test_migration_writes_sidecars_and_strips_bodies(app_with_fake_gitea):
_app, fake = app_with_fake_gitea
_seed_entries(fake)
summary = _run_migration()
assert sorted(summary["migrated"]) == ["alpha", "beta"]
# Sidecars now exist.
alpha_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
beta_sc = fake.files[("wiggleverse", "meta", "main", "rfcs/beta.meta.yaml")]["content"]
alpha_vals = yaml.safe_load(alpha_sc)
assert alpha_vals["slug"] == "alpha"
assert alpha_vals["title"] == "Alpha"
assert alpha_vals["tags"] == ["one"]
# INV-7: the unknown key rides along into the sidecar.
assert alpha_vals["priority"] == "P1"
# .md bodies are stripped of frontmatter (INV-2).
alpha_md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
assert "---" not in alpha_md
assert "priority:" not in alpha_md
assert alpha_md.strip() == "Alpha body prose."
assert beta_sc # beta got a sidecar too
def test_migration_is_idempotent(app_with_fake_gitea):
_app, fake = app_with_fake_gitea
_seed_entries(fake)
first = _run_migration()
assert sorted(first["migrated"]) == ["alpha", "beta"]
assert first["committed"] is True
commits_after_first = fake._commit_counter
alpha_md_after_first = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
second = _run_migration()
assert second["migrated"] == []
assert second["committed"] is False
# No new commit; files untouched.
assert fake._commit_counter == commits_after_first
assert fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"] == alpha_md_after_first
def test_migration_one_commit_for_whole_collection(app_with_fake_gitea):
_app, fake = app_with_fake_gitea
_seed_entries(fake)
before = fake._commit_counter
_run_migration()
# Two entries migrated in exactly one commit (ChangeFiles batch).
assert fake._commit_counter == before + 1
def test_migration_dual_read_equivalence_after_migrate(app_with_fake_gitea):
"""An entry reads identically before and after migration (INV-6)."""
_app, fake = app_with_fake_gitea
_seed_entries(fake)
before, _ = metadata.read_entry(ALPHA_MD, None)
_run_migration()
md = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")]["content"]
sc = fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")]["content"]
after, malformed = metadata.read_entry(md, sc)
assert malformed is False
assert after.slug == before.slug
assert after.title == before.title
assert after.state == before.state
assert after.tags == before.tags
assert after.extra == before.extra
assert after.body.strip() == before.body.strip()
+22
View File
@@ -280,6 +280,28 @@ class FakeGitea:
return httpx.Response(200, json=children)
return httpx.Response(404, json={"message": "not found"})
# POST /repos/{owner}/{repo}/contents — ChangeFiles (batch, one commit).
# §22.4a SLICE-1: the frontmatter→sidecar migration writes N files in a
# single commit. Matches the no-path /contents route (the per-path POST
# below needs a /contents/<path> suffix).
m_batch = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
if method == "POST" and m_batch:
owner, repo = m_batch.groups()
branch = payload["branch"]
sha = self._next_sha()
for f in payload["files"]:
op = f["operation"]
fpath = f["path"]
if op == "delete":
self.files.pop((owner, repo, branch, fpath), None)
else:
content = base64.b64decode(f["content"]).decode()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
br = self.branches[(owner, repo)].setdefault(branch, {})
br["sha"] = sha
br["ts"] = "2026-05-23T00:00:00Z"
return httpx.Response(201, json={"commit": {"sha": sha}})
# POST /repos/{owner}/{repo}/contents/{path}
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
if method == "POST" and m: