feat(content): structured content schema models + renderers

This commit is contained in:
Renn F
2026-06-21 01:44:07 +02:00
parent 8fa6104af1
commit f0368af757
6 changed files with 924 additions and 0 deletions
@@ -0,0 +1,45 @@
"""Structured agent-content schema — the RoboCo content standard.
Public surface: the content models, the ``CONTENT_MODELS`` registry, the
``validate_content`` entry point, and the ``ContentValidationError`` raised on
failure. See :mod:`.models` for the per-type schemas.
"""
from __future__ import annotations
from .enums import Severity, Verdict
from .models import (
CONTENT_MODELS,
AcVerdict,
AuditorNote,
DeveloperNote,
DocNote,
Finding,
PrReviewContent,
QaNote,
ResumptionNote,
TaskDescription,
WorkUnit,
required_shape,
validate_content,
)
from .validators import ContentValidationError
__all__ = [
"CONTENT_MODELS",
"AcVerdict",
"AuditorNote",
"ContentValidationError",
"DeveloperNote",
"DocNote",
"Finding",
"PrReviewContent",
"QaNote",
"ResumptionNote",
"Severity",
"TaskDescription",
"Verdict",
"WorkUnit",
"required_shape",
"validate_content",
]
+35
View File
@@ -0,0 +1,35 @@
"""Enumerations for structured agent content.
These are the controlled vocabularies the content schema models
(:mod:`roboco.foundation.policy.content.models`) validate against. The cell
``Team`` vocabulary is reused from :mod:`roboco.foundation.identity` rather
than redefined here.
"""
from __future__ import annotations
from enum import StrEnum
class Severity(StrEnum):
"""Severity ladder for a PR-review finding (worst → least)."""
BLOCKER = "blocker" # must fix before merge
MAJOR = "major" # significant defect
MINOR = "minor" # small defect, fix advised
NIT = "nit" # cosmetic / preference
class Verdict(StrEnum):
"""Outcome of a review.
``approved`` / ``changes_requested`` describe a PR review; ``passed`` /
``failed`` describe a QA or in-path gate review. All four are valid
``PrReviewContent`` verdicts; ``QaNote`` accepts only ``passed`` /
``failed``.
"""
APPROVED = "approved"
CHANGES_REQUESTED = "changes_requested"
PASSED = "passed"
FAILED = "failed"
+447
View File
@@ -0,0 +1,447 @@
"""Structured content schema models.
Every human-facing artifact an agent produces — PR-review comments, task
notes, task descriptions, resumption context — is one of these typed models.
Each model:
- validates its named fields (non-trivial, required, controlled vocab);
- coerces a lone scalar where a list is declared (graceful, never a hard
reject of the well-intentioned single-item case);
- renders to canonical labeled markdown via ``render_markdown()``.
The structured payload is the source of truth; the rendered markdown is the
derived mirror written to the Task's TEXT note columns and to PR comment
bodies. ``CONTENT_MODELS`` maps the gateway's content-type key to its model;
``validate_content`` is the single entry point that turns a raw payload into a
validated model (or a ``ContentValidationError``).
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationError,
ValidationInfo,
field_validator,
model_validator,
)
from roboco.foundation.identity import CELL_TEAMS, Team
from .enums import Severity, Verdict
from .validators import ContentValidationError, coerce_to_list, reject_trivial
_SUMMARY_MIN = 10
class _Base(BaseModel):
"""Shared config: drop unknown keys (graceful), validate assignment."""
model_config = ConfigDict(extra="ignore", validate_assignment=True)
class _Content(_Base):
"""A top-level content type that renders to canonical markdown."""
def render_markdown(self) -> str: # pragma: no cover - overridden
raise NotImplementedError
# --------------------------------------------------------------------------- #
# Markdown helpers
# --------------------------------------------------------------------------- #
def _bullets(items: list[str]) -> str:
return "\n".join(f"- {i.strip()}" for i in items if i and i.strip())
def _section(title: str, body: str) -> str:
body = body.strip()
return f"## {title}\n{body}" if body else ""
def _join(parts: list[str]) -> str:
return "\n\n".join(p for p in parts if p).strip()
# --------------------------------------------------------------------------- #
# Sub-models
# --------------------------------------------------------------------------- #
class Finding(_Base):
"""One PR-review finding — file + line + expected vs actual."""
file: str
line: int | None = None
severity: Severity
criterion: str | None = None
expected: str
actual: str
@field_validator("file", "expected", "actual")
@classmethod
def _nontrivial(cls, v: str, info: ValidationInfo) -> str:
return reject_trivial(v, field=info.field_name or "field")
class WorkUnit(_Base):
"""One cell's slice of a task description."""
team: Team
summary: str
items: list[str] = Field(default_factory=list)
@field_validator("items", mode="before")
@classmethod
def _coerce_items(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("team")
@classmethod
def _cell_team_only(cls, v: Team) -> Team:
if v not in CELL_TEAMS:
raise ValueError(
f"team must be a cell ({', '.join(t.value for t in CELL_TEAMS)})"
)
return v
@field_validator("summary")
@classmethod
def _nontrivial_summary(cls, v: str) -> str:
return reject_trivial(v, field="summary")
@field_validator("items")
@classmethod
def _nonempty_items(cls, v: list[str]) -> list[str]:
cleaned = [i for i in v if i and i.strip()]
if not cleaned:
raise ValueError("items must list at least one work item")
return cleaned
class AcVerdict(_Base):
"""One acceptance-criterion verdict from a QA review."""
criterion: str
status: Literal["verified", "failed", "na"]
how: str
@field_validator("criterion", "how")
@classmethod
def _nontrivial(cls, v: str, info: ValidationInfo) -> str:
return reject_trivial(v, field=info.field_name or "field")
# --------------------------------------------------------------------------- #
# Top-level content models
# --------------------------------------------------------------------------- #
class PrReviewContent(_Content):
"""A PR-review comment / reviewer verdict."""
summary: str
findings: list[Finding] = Field(default_factory=list)
verdict: Verdict
@field_validator("findings", mode="before")
@classmethod
def _coerce_findings(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("summary")
@classmethod
def _nontrivial_summary(cls, v: str) -> str:
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
@model_validator(mode="after")
def _findings_required_for_negative(self) -> PrReviewContent:
if self.verdict in (Verdict.CHANGES_REQUESTED, Verdict.FAILED) and not (
self.findings
):
raise ValueError(
"findings must be non-empty when verdict is changes_requested or failed"
)
return self
def render_markdown(self) -> str:
parts = [_section("Summary", self.summary)]
if self.findings:
rows = [
"| File | Line | Severity | Expected → Actual |",
"| --- | --- | --- | --- |",
]
for f in self.findings:
loc = str(f.line) if f.line is not None else ""
crit = f" ({f.criterion})" if f.criterion else ""
rows.append(
f"| `{f.file}`{crit} | {loc} | {f.severity.value} "
f"| {f.expected}{f.actual} |"
)
parts.append("## Findings\n" + "\n".join(rows))
parts.append(_section("Verdict", self.verdict.value.replace("_", " ")))
return _join(parts)
class TaskDescription(_Content):
"""A well-formed task description (shared by PM delegate + Intake draft)."""
objective: str
what_this_builds: list[str] = Field(default_factory=list)
the_work: list[WorkUnit] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
acceptance_criteria: list[str] = Field(default_factory=list)
@field_validator(
"what_this_builds",
"the_work",
"notes",
"acceptance_criteria",
mode="before",
)
@classmethod
def _coerce(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("objective")
@classmethod
def _nontrivial_objective(cls, v: str) -> str:
return reject_trivial(v, field="objective", min_chars=_SUMMARY_MIN)
@field_validator("the_work")
@classmethod
def _nonempty_work(cls, v: list[WorkUnit]) -> list[WorkUnit]:
if not v:
raise ValueError("the_work must contain at least one work unit")
return v
@field_validator("acceptance_criteria")
@classmethod
def _nonempty_ac(cls, v: list[str]) -> list[str]:
cleaned = [i for i in v if i and i.strip()]
if not cleaned:
raise ValueError("acceptance_criteria must list at least one criterion")
return cleaned
def render_markdown(self) -> str:
parts = [_section("Objective", self.objective)]
if self.what_this_builds:
parts.append(_section("What This Builds", _bullets(self.what_this_builds)))
if self.the_work:
units = []
for u in self.the_work:
head = f"**{u.team.value.replace('_', ' ').title()}** — {u.summary}"
units.append(f"{head}\n{_bullets(u.items)}")
parts.append("## The Work\n" + "\n\n".join(units))
if self.notes:
parts.append(_section("Notes", _bullets(self.notes)))
parts.append(
_section("Acceptance Criteria", _bullets(self.acceptance_criteria))
)
return _join(parts)
class ResumptionNote(_Content):
"""The human handoff that lives in ``quick_context`` (no machine markers)."""
done: str
next: str
where_to_look: list[str] = Field(default_factory=list)
@field_validator("where_to_look", mode="before")
@classmethod
def _coerce(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("done", "next")
@classmethod
def _nontrivial(cls, v: str, info: ValidationInfo) -> str:
return reject_trivial(v, field=info.field_name or "field")
def render_markdown(self) -> str:
parts = [_section("Done", self.done), _section("Next", self.next)]
if self.where_to_look:
parts.append(_section("Where to look", _bullets(self.where_to_look)))
return _join(parts)
class DeveloperNote(_Content):
"""A developer's task note."""
summary: str
changes: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
follow_ups: list[str] = Field(default_factory=list)
@field_validator("changes", "risks", "follow_ups", mode="before")
@classmethod
def _coerce(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("summary")
@classmethod
def _nontrivial(cls, v: str) -> str:
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
def render_markdown(self) -> str:
parts = [_section("Summary", self.summary)]
if self.changes:
parts.append(_section("Changes", _bullets(self.changes)))
if self.risks:
parts.append(_section("Risks", _bullets(self.risks)))
if self.follow_ups:
parts.append(_section("Follow-ups", _bullets(self.follow_ups)))
return _join(parts)
class QaNote(_Content):
"""A QA review note — summary + per-criterion verdicts + outcome."""
summary: str
ac_verdicts: list[AcVerdict] = Field(default_factory=list)
verdict: Verdict
@field_validator("ac_verdicts", mode="before")
@classmethod
def _coerce(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("summary")
@classmethod
def _nontrivial(cls, v: str) -> str:
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
@field_validator("verdict")
@classmethod
def _passed_or_failed(cls, v: Verdict) -> Verdict:
if v not in (Verdict.PASSED, Verdict.FAILED):
raise ValueError("QA verdict must be 'passed' or 'failed'")
return v
@field_validator("ac_verdicts")
@classmethod
def _nonempty(cls, v: list[AcVerdict]) -> list[AcVerdict]:
if not v:
raise ValueError("ac_verdicts must cover at least one acceptance criterion")
return v
def render_markdown(self) -> str:
parts = [_section("Summary", self.summary)]
marks = {"verified": "", "failed": "", "na": ""}
rows = [
f"- {marks[a.status]} **{a.criterion}** — {a.how}" for a in self.ac_verdicts
]
parts.append("## Acceptance Criteria\n" + "\n".join(rows))
parts.append(_section("Verdict", self.verdict.value))
return _join(parts)
class DocNote(_Content):
"""A documenter's note — what was documented vs deliberately skipped."""
summary: str
documented: list[str] = Field(default_factory=list)
skipped: list[str] = Field(default_factory=list)
@field_validator("documented", "skipped", mode="before")
@classmethod
def _coerce(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("summary")
@classmethod
def _nontrivial(cls, v: str) -> str:
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
def render_markdown(self) -> str:
parts = [_section("Summary", self.summary)]
if self.documented:
parts.append(_section("Documented", _bullets(self.documented)))
if self.skipped:
parts.append(_section("Skipped", _bullets(self.skipped)))
return _join(parts)
class AuditorNote(_Content):
"""An auditor's confidential observation."""
summary: str
concerns: list[str] = Field(default_factory=list)
severity: Literal["info", "watch", "risk"]
@field_validator("concerns", mode="before")
@classmethod
def _coerce(cls, v: Any) -> Any:
return coerce_to_list(v)
@field_validator("summary")
@classmethod
def _nontrivial(cls, v: str) -> str:
return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN)
def render_markdown(self) -> str:
parts = [
_section("Summary", self.summary),
_section("Severity", self.severity),
]
if self.concerns:
parts.append(_section("Concerns", _bullets(self.concerns)))
return _join(parts)
# --------------------------------------------------------------------------- #
# Registry + single validation entry point
# --------------------------------------------------------------------------- #
CONTENT_MODELS: dict[str, type[_Content]] = {
"pr_review": PrReviewContent,
"task_description": TaskDescription,
"resumption": ResumptionNote,
"developer": DeveloperNote,
"qa": QaNote,
"doc": DocNote,
"auditor": AuditorNote,
}
def validate_content(content_type: str, payload: Any) -> _Content:
"""Validate ``payload`` against the model for ``content_type``.
Returns the validated model (passed through unchanged if it is already an
instance of the right model). Raises ``ContentValidationError(field,
reason)`` — built from Pydantic's first error — on any failure, including an
unknown content type.
"""
model_cls = CONTENT_MODELS.get(content_type)
if model_cls is None:
raise ContentValidationError(
"content_type", f"unknown content type: {content_type!r}"
)
if isinstance(payload, model_cls):
return payload
try:
return model_cls.model_validate(
payload if isinstance(payload, dict) else dict(payload or {})
)
except ValidationError as exc:
first = exc.errors()[0]
loc = ".".join(str(p) for p in first.get("loc", ())) or "?"
raise ContentValidationError(loc, first.get("msg", "invalid")) from exc
def required_shape(content_type: str) -> dict[str, str]:
"""A ``{field: type-hint}`` map for a content type, for remediation hints."""
model_cls = CONTENT_MODELS.get(content_type)
if model_cls is None:
return {}
shape: dict[str, str] = {}
for name, field in model_cls.model_fields.items():
ann = field.annotation
shape[name] = getattr(ann, "__name__", str(ann))
return shape
@@ -0,0 +1,85 @@
"""Shared validation primitives for structured agent content.
Two jobs:
- ``reject_trivial`` — the non-empty / non-placeholder gate, reused by every
content model's field validators. Raises ``ValueError`` so it composes with
Pydantic field validators (which collect ``ValueError`` into a
``ValidationError``).
- ``ContentValidationError`` — the public, gateway-facing exception. The
top-level ``validate_content`` (in :mod:`.models`) converts Pydantic's
``ValidationError`` into this so callers get a single ``(field, reason)``
shape to build a remediation envelope from.
"""
from __future__ import annotations
from typing import Any
# Placeholder tokens that are never an acceptable whole-field value. Extends the
# commit-validator's banned single-word list (services/gateway/commit_validator).
BANNED_PHRASES: frozenset[str] = frozenset(
{
"wip",
"tmp",
"tbd",
"todo",
"asdf",
"oops",
"stuff",
"things",
"n/a",
"na",
"none",
"null",
"-",
"--",
"...",
".",
"x",
}
)
class ContentValidationError(Exception):
"""A structured-content payload failed validation.
Carries the offending ``field`` and a human ``reason`` so the gateway can
return a remediable envelope (``{error, message, remediate, missing}``).
"""
def __init__(self, field: str, reason: str) -> None:
self.field = field
self.reason = reason
super().__init__(f"{field}: {reason}")
def reject_trivial(value: str, *, field: str, min_chars: int = 1) -> str:
"""Return the trimmed value, or raise ``ValueError`` if it is trivial.
Trivial = empty, shorter than ``min_chars``, or a known placeholder token.
Raises ``ValueError`` (not ``ContentValidationError``) so it can be used
directly inside Pydantic field validators.
"""
text = (value or "").strip()
if not text:
raise ValueError(f"{field} must not be empty")
if len(text) < min_chars:
raise ValueError(f"{field} must be at least {min_chars} characters")
if text.lower() in BANNED_PHRASES:
raise ValueError(f"{field} must not be placeholder text (got {value!r})")
return text
def coerce_to_list(value: Any) -> Any:
"""Wrap a lone scalar/dict into a one-element list; pass lists/None through.
Mirrors ``api.schemas.v1.do._coerce_to_list``: an agent that passes a single
string (or dict) where a list is declared is making the well-intentioned
single-item mistake — wrap it rather than reject it.
"""
if value is None or isinstance(value, list):
return value
if isinstance(value, str | dict):
return [value]
return value
@@ -0,0 +1,220 @@
"""Validation tests for the structured content schema models."""
from __future__ import annotations
import pytest
from roboco.foundation.policy.content import (
AuditorNote,
ContentValidationError,
DeveloperNote,
DocNote,
PrReviewContent,
QaNote,
ResumptionNote,
TaskDescription,
validate_content,
)
from roboco.foundation.policy.content.enums import Severity, Verdict
# --------------------------------------------------------------------------- #
# Valid construction
# --------------------------------------------------------------------------- #
def test_pr_review_valid() -> None:
c = PrReviewContent(
summary="The change is correct and covered by tests.",
findings=[
{
"file": "roboco/services/git.py",
"line": 42,
"severity": "major",
"expected": "raises on 422",
"actual": "swallows the error",
}
],
verdict="changes_requested",
)
assert c.findings[0].severity is Severity.MAJOR
assert c.verdict is Verdict.CHANGES_REQUESTED
def test_qa_valid() -> None:
c = QaNote(
summary="Reviewed all acceptance criteria against the diff.",
ac_verdicts=[
{"criterion": "AC1 returns 400", "status": "verified", "how": "test passes"}
],
verdict="passed",
)
assert c.ac_verdicts[0].status == "verified"
def test_task_description_valid() -> None:
c = TaskDescription(
objective="Add a structured PR-review comment format.",
what_this_builds=["a reviewer schema"],
the_work=[
{
"team": "backend",
"summary": "schema + gateway",
"items": ["model", "verb"],
}
],
acceptance_criteria=["reviewer notes land in their own slot"],
)
assert c.the_work[0].team.value == "backend"
def test_resumption_valid() -> None:
c = ResumptionNote(
done="schema landed", next="wire the gateway", where_to_look=["models.py"]
)
assert c.next == "wire the gateway"
# --------------------------------------------------------------------------- #
# Rejection
# --------------------------------------------------------------------------- #
def test_validate_content_unknown_type() -> None:
with pytest.raises(ContentValidationError) as exc:
validate_content("nonsense", {})
assert exc.value.field == "content_type"
def test_pr_review_missing_summary_rejected() -> None:
with pytest.raises(ContentValidationError) as exc:
validate_content("pr_review", {"verdict": "approved"})
assert exc.value.field == "summary"
def test_pr_review_trivial_summary_rejected() -> None:
with pytest.raises(ContentValidationError):
validate_content("pr_review", {"summary": "wip", "verdict": "approved"})
def test_pr_review_negative_verdict_requires_findings() -> None:
with pytest.raises(ContentValidationError):
validate_content(
"pr_review",
{
"summary": "Looks broken but no detail given here.",
"verdict": "failed",
"findings": [],
},
)
def test_pr_review_approved_allows_empty_findings() -> None:
c = validate_content(
"pr_review",
{"summary": "All criteria met, nothing to change.", "verdict": "approved"},
)
assert isinstance(c, PrReviewContent)
def test_qa_verdict_must_be_pass_or_fail() -> None:
with pytest.raises(ContentValidationError) as exc:
validate_content(
"qa",
{
"summary": "Reviewed the acceptance criteria thoroughly.",
"ac_verdicts": [
{"criterion": "c", "status": "verified", "how": "test"}
],
"verdict": "approved",
},
)
assert exc.value.field == "verdict"
def test_qa_requires_ac_verdicts() -> None:
with pytest.raises(ContentValidationError) as exc:
validate_content(
"qa",
{
"summary": "Reviewed everything carefully here.",
"ac_verdicts": [],
"verdict": "passed",
},
)
assert exc.value.field == "ac_verdicts"
def test_task_description_requires_work() -> None:
with pytest.raises(ContentValidationError) as exc:
validate_content(
"task_description",
{
"objective": "Build the thing properly.",
"the_work": [],
"acceptance_criteria": ["x"],
},
)
assert exc.value.field == "the_work"
def test_work_unit_rejects_non_cell_team() -> None:
with pytest.raises(ContentValidationError):
validate_content(
"task_description",
{
"objective": "Build the thing properly.",
"the_work": [{"team": "board", "summary": "do it", "items": ["a"]}],
"acceptance_criteria": ["x"],
},
)
# --------------------------------------------------------------------------- #
# Graceful coercion (lone scalar -> one-element list)
# --------------------------------------------------------------------------- #
def test_findings_single_dict_coerced_to_list() -> None:
c = validate_content(
"pr_review",
{
"summary": "One finding passed as a bare dict.",
"verdict": "changes_requested",
"findings": {
"file": "a.py",
"severity": "nit",
"expected": "trailing newline",
"actual": "no newline",
},
},
)
assert isinstance(c, PrReviewContent)
assert len(c.findings) == 1
def test_where_to_look_single_string_coerced() -> None:
c = validate_content(
"resumption",
{
"done": "did the thing",
"next": "do next thing",
"where_to_look": "models.py",
},
)
assert isinstance(c, ResumptionNote)
assert c.where_to_look == ["models.py"]
def test_developer_and_doc_and_auditor_models() -> None:
assert isinstance(
validate_content("developer", {"summary": "Implemented the schema layer."}),
DeveloperNote,
)
assert isinstance(
validate_content("doc", {"summary": "Documented the new endpoints."}), DocNote
)
assert isinstance(
validate_content(
"auditor", {"summary": "No concerns with this change.", "severity": "info"}
),
AuditorNote,
)
@@ -0,0 +1,92 @@
"""Markdown rendering tests for the structured content models.
Assert structure (labeled sections + the content appears) rather than exact
bytes, so the renderers stay refactorable.
"""
from __future__ import annotations
from roboco.foundation.policy.content import (
PrReviewContent,
QaNote,
ResumptionNote,
TaskDescription,
)
def test_pr_review_renders_sections_and_findings_table() -> None:
md = PrReviewContent(
summary="The guard is missing on the 422 path.",
findings=[
{
"file": "roboco/services/git.py",
"line": 42,
"severity": "blocker",
"expected": "retry as COMMENT",
"actual": "raises",
}
],
verdict="changes_requested",
).render_markdown()
assert "## Summary" in md
assert "## Findings" in md
assert "| File | Line | Severity | Expected → Actual |" in md
assert "`roboco/services/git.py`" in md
assert "blocker" in md
assert "## Verdict" in md
assert "changes requested" in md
def test_qa_renders_checklist() -> None:
md = QaNote(
summary="Verified every acceptance criterion against the diff.",
ac_verdicts=[
{
"criterion": "AC1 returns 400",
"status": "verified",
"how": "test passes",
},
{"criterion": "AC2 logs", "status": "failed", "how": "no log emitted"},
],
verdict="failed",
).render_markdown()
assert "## Acceptance Criteria" in md
assert "" in md and "" in md
assert "AC1 returns 400" in md
assert "## Verdict" in md
def test_resumption_renders_done_next_where() -> None:
md = ResumptionNote(
done="schema module landed",
next="wire the gateway chokepoint",
where_to_look=[
"foundation/policy/content/models.py",
"services/content_notes.py",
],
).render_markdown()
assert "## Done" in md
assert "## Next" in md
assert "## Where to look" in md
assert "content_notes.py" in md
# No machine markers ever leak into the human resumption note.
assert "original_developer" not in md
assert "documenter:" not in md
def test_task_description_renders_all_sections() -> None:
md = TaskDescription(
objective="Add the structured content standard.",
what_this_builds=["a schema layer", "a gateway gate"],
the_work=[
{"team": "backend", "summary": "models + verbs", "items": ["model", "verb"]}
],
notes=["reuse the Team enum"],
acceptance_criteria=["reviewer notes are isolated"],
).render_markdown()
assert "## Objective" in md
assert "## What This Builds" in md
assert "## The Work" in md
assert "**Backend**" in md
assert "## Notes" in md
assert "## Acceptance Criteria" in md