[F109] playbook curation status guards: approve/reject draft-only, archive approved-only

This commit is contained in:
Renn F
2026-06-28 22:21:32 +02:00
parent 27b48dd64e
commit f779fe7453
8 changed files with 284 additions and 21 deletions
+35 -2
View File
@@ -13,7 +13,7 @@ from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.playbook import PlaybookRejectBody
from roboco.models import AgentRole
from roboco.models.playbook import Playbook
from roboco.services.base import NotFoundError
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import get_playbook_service
router = APIRouter()
@@ -59,6 +59,12 @@ async def approve_playbook(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
) from exc
except ConflictError as exc:
# The playbook is not a draft (already approved/archived) — the
# curation is already finished, not a missing resource.
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
# Commit the status change BEFORE indexing: the RAG index write runs through
# its own auto-committing connection, so indexing before commit would durably
# land an approved playbook in the corpus even if this commit rolled back.
@@ -74,7 +80,7 @@ async def reject_playbook(
db: DbSession,
agent: CurrentAgentContext,
) -> Playbook:
"""Reject a playbook → archived, with the Auditor's reason."""
"""Reject a draft playbook → archived, with the Auditor's reason."""
_require_curator(agent)
try:
svc = get_playbook_service(db)
@@ -85,6 +91,33 @@ async def reject_playbook(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
) from exc
except ConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
# Commit the status change BEFORE de-indexing (see approve_playbook).
await db.commit()
await svc.unindex_playbook(playbook)
return Playbook.model_validate(playbook)
@router.post("/{playbook_id}/archive", response_model=Playbook)
async def archive_playbook(
playbook_id: UUID, db: DbSession, agent: CurrentAgentContext
) -> Playbook:
"""Retire an approved playbook → archived (and de-indexed from the KB)."""
_require_curator(agent)
try:
svc = get_playbook_service(db)
playbook = await svc.archive(playbook_id, approver_id=agent.agent_id)
except NotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Playbook not found"
) from exc
except ConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
# Commit the status change BEFORE de-indexing (see approve_playbook).
await db.commit()
await svc.unindex_playbook(playbook)
+20 -4
View File
@@ -777,12 +777,11 @@ class ContentActions:
)
async def archive_playbook(self, *, agent_id: UUID, playbook_id: UUID) -> Envelope:
"""Auditor archives a playbook (-> archived)."""
"""Auditor archives an approved playbook (-> archived, retired)."""
return await self._curate_playbook(
agent_id=agent_id,
playbook_id=playbook_id,
action="reject",
reason="archived",
action="archive",
)
async def _curate_playbook(
@@ -801,7 +800,7 @@ class ContentActions:
remediate="Only the Auditor approves/rejects/archives playbooks.",
context_briefing={},
)
from roboco.services.base import NotFoundError
from roboco.services.base import ConflictError, NotFoundError
from roboco.services.playbook import get_playbook_service
svc = get_playbook_service(self.task.session)
@@ -809,6 +808,9 @@ class ContentActions:
if action == "approve":
playbook = await svc.approve(playbook_id, approver_id=agent_id)
status = "playbook_approved"
elif action == "archive":
playbook = await svc.archive(playbook_id, approver_id=agent_id)
status = "playbook_archived"
else:
playbook = await svc.reject(
playbook_id, approver_id=agent_id, reason=reason or action
@@ -816,6 +818,20 @@ class ContentActions:
status = "playbook_archived"
except NotFoundError:
return Envelope.not_found(message=f"playbook {playbook_id} not found")
except ConflictError as exc:
# A status-precondition violation (approve/reject on a non-draft,
# archive on a non-approved) is a clean invalid_state, not a 500 —
# the agent gets a remediate hint to re-fetch the playbook's
# current status before re-trying.
return Envelope.invalid_state(
message=str(exc),
remediate=(
"Only a draft can be approved/rejected; only an approved "
"playbook can be archived. Re-list drafts/approved to see "
"the playbook's current status before re-trying."
),
context_briefing={"playbook_id": str(playbook_id)},
)
# Commit the status change BEFORE touching the RAG index: the index write
# runs through its own auto-committing connection, so indexing before the
# status commit would durably land (or drop) a playbook in the corpus even
+37
View File
@@ -78,6 +78,12 @@ class PlaybookService(BaseService):
agents then surfaced in briefings.
"""
playbook = await self._get_or_raise(playbook_id)
if playbook.status != PlaybookStatus.DRAFT.value:
raise ConflictError(
f"Playbook {playbook_id} is {playbook.status}, not draft — "
"only a draft can be approved",
resource_type="playbook",
)
playbook.status = PlaybookStatus.APPROVED.value
playbook.approved_by = approver_id
playbook.approved_at = datetime.now(UTC)
@@ -85,6 +91,31 @@ class PlaybookService(BaseService):
self.log.info("Playbook approved", playbook_id=str(playbook_id))
return playbook
async def archive(self, playbook_id: UUID, approver_id: UUID) -> PlaybookTable:
"""Auditor retires an APPROVED playbook: approved -> archived.
The distinct curation transition from :meth:`reject`: ``reject``
declines a DRAFT (never published); ``archive`` retires an APPROVED
playbook already in circulation. Both end in ARCHIVED, but they start
from different states, so each guards its own precondition. An ARCHIVED
playbook is terminal neither approve, reject, nor archive may touch
it again. Like reject, the status flush is the only in-tx step; the
post-commit ``unindex_playbook`` is the caller's separate step.
"""
playbook = await self._get_or_raise(playbook_id)
if playbook.status != PlaybookStatus.APPROVED.value:
raise ConflictError(
f"Playbook {playbook_id} is {playbook.status}, not approved — "
"only an approved playbook can be archived",
resource_type="playbook",
)
playbook.status = PlaybookStatus.ARCHIVED.value
playbook.approved_by = approver_id
playbook.approved_at = datetime.now(UTC)
await self.session.flush()
self.log.info("Playbook archived", playbook_id=str(playbook_id))
return playbook
async def index_approved(self, playbook: PlaybookTable) -> None:
"""Embed an approved playbook into the PLAYBOOKS RAG index (best-effort).
@@ -129,6 +160,12 @@ class PlaybookService(BaseService):
post-commit step (see ``approve`` for the ordering rationale).
"""
playbook = await self._get_or_raise(playbook_id)
if playbook.status != PlaybookStatus.DRAFT.value:
raise ConflictError(
f"Playbook {playbook_id} is {playbook.status}, not draft — "
"only a draft can be rejected",
resource_type="playbook",
)
playbook.status = PlaybookStatus.ARCHIVED.value
playbook.approved_by = approver_id
playbook.approved_at = datetime.now(UTC)