diff --git a/backend/app/entry.py b/backend/app/entry.py index 7d5d2a6..1355bf3 100644 --- a/backend/app/entry.py +++ b/backend/app/entry.py @@ -50,6 +50,13 @@ class Entry: # operator credentials per §18 are used. The binding is inert until # the named user has a funder_consents row (the hybrid two-key rule). funder: str | None = None + # §22.4c: an `active` entry that landed without a human review gate + # carries unreviewed=True until an owner clears it. Orthogonal to + # `state`; only meaningful for active entries. reviewed_at/reviewed_by + # are the provenance of the clear, paralleling graduated_at/by. + unreviewed: bool = False + reviewed_at: str | None = None + reviewed_by: str | None = None body: str = "" @@ -66,6 +73,7 @@ def parse(text: str) -> Entry: models = [str(m) for m in raw_models] raw_funder = fm.get("funder") funder = str(raw_funder).strip() if raw_funder else None + unreviewed = bool(fm.get("unreviewed") or False) return Entry( slug=str(fm.get("slug") or ""), title=str(fm.get("title") or ""), @@ -81,6 +89,9 @@ def parse(text: str) -> Entry: tags=list(fm.get("tags") or []), models=models, funder=funder, + unreviewed=unreviewed, + reviewed_at=fm.get("reviewed_at") or None, + reviewed_by=fm.get("reviewed_by") or None, body=body, ) @@ -110,6 +121,14 @@ def serialize(entry: Entry) -> str: # second meaning here as with `models:`; one set of semantics. if entry.funder: fm["funder"] = entry.funder + # §22.4c: emit unreviewed only when True (a super-draft / reviewed + # active entry leaves the key absent → frontmatter stays minimal). + if entry.unreviewed: + fm["unreviewed"] = True + if entry.reviewed_at: + fm["reviewed_at"] = entry.reviewed_at + if entry.reviewed_by: + fm["reviewed_by"] = entry.reviewed_by yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip() body = entry.body.lstrip("\n") if body: diff --git a/backend/tests/test_entry_review_fields.py b/backend/tests/test_entry_review_fields.py new file mode 100644 index 0000000..d2f8994 --- /dev/null +++ b/backend/tests/test_entry_review_fields.py @@ -0,0 +1,42 @@ +"""§22.4c — the unreviewed/reviewed_at/reviewed_by entry frontmatter fields.""" +from __future__ import annotations + +from app import entry as entry_mod + + +def test_parse_defaults_unreviewed_false_when_absent(): + text = "---\nslug: ohm\ntitle: OHM\nstate: active\n---\n\nBody.\n" + e = entry_mod.parse(text) + assert e.unreviewed is False + assert e.reviewed_at is None + assert e.reviewed_by is None + + +def test_parse_reads_review_fields(): + text = ( + "---\nslug: ohm\ntitle: OHM\nstate: active\n" + "unreviewed: true\nreviewed_at: '2026-06-03'\nreviewed_by: ben\n---\n\nBody.\n" + ) + e = entry_mod.parse(text) + assert e.unreviewed is True + assert e.reviewed_at == "2026-06-03" + assert e.reviewed_by == "ben" + + +def test_serialize_emits_review_fields_only_when_meaningful(): + e = entry_mod.Entry(slug="a", title="A", state="super-draft") + assert "unreviewed" not in entry_mod.serialize(e) + assert "reviewed_at" not in entry_mod.serialize(e) + e2 = entry_mod.Entry(slug="b", title="B", state="active", unreviewed=True) + assert "unreviewed: true" in entry_mod.serialize(e2) + + +def test_round_trip_preserves_review_fields(): + e = entry_mod.Entry( + slug="b", title="B", state="active", + unreviewed=False, reviewed_at="2026-06-03", reviewed_by="ben", + ) + back = entry_mod.parse(entry_mod.serialize(e)) + assert back.reviewed_at == "2026-06-03" + assert back.reviewed_by == "ben" + assert back.unreviewed is False