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