Files
justin abaa8efdd5 Fix #214: ChangeProposal (propose-then-confirm)
Implements non-negotiable #1: the AI assistant never writes autonomously. Every
assistant/contributor "write" emits a ChangeProposal — a structured diff a human
approves, edits, or rejects. Design: docs/design/change-proposal.md.

Structural guarantee: a proposal's operations reach the DB ONLY via
change_proposal_service.apply(), which requires the actor be an editor and
dispatches each op through the normal editing services (person/name/event/
relationship/source/citation create/update/delete) — so every change passes the
privacy engine and is audited as the approving human. propose() only inserts a
pending row; it performs no domain mutation. Model providers stay read-only, so
no model response can mutate tree data.

- ChangeProposal model + migration (status pending|applied|rejected, origin
  assistant|contributor, JSONB operations, reviewer + apply_error).
- Service: propose / list / get / apply (with optional edited ops) / reject /
  delete; a dispatcher mapping ops → editing services. v1 applies ops in order,
  not cross-op transactional (single-op is atomic; documented).
- API /trees/{id}/proposals + a frontend review page (approve/reject; editor-
  gated) and sidebar entry.

Tests: proposal doesn't apply until approved; reject doesn't apply; non-editor
member can see but not apply; multi-op; approve-with-edits; apply-error keeps it
pending. Full suite 87 passed; single alembic head.

Closes #214

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 15:44:40 -04:00

117 lines
4.1 KiB
Python

"""Change-proposal endpoints: list / create / get / apply / reject / delete.
Applying a proposal is the only way its operations reach the database, and only
an editor can do it (enforced in the service). See docs/design/change-proposal.md.
"""
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.models.enums import ChangeProposalStatus
from app.schemas.change_proposal import (
ChangeProposalCreate,
ChangeProposalRead,
ProposalReview,
)
from app.services import change_proposal_service, tree_service
router = APIRouter(prefix="/trees", tags=["proposals"])
@router.get("/{tree_id}/proposals", response_model=list[ChangeProposalRead])
async def list_proposals(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
status: ChangeProposalStatus | None = None,
) -> list[ChangeProposalRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await change_proposal_service.list_proposals(
session, viewer_id=current.id, tree=tree, status=status
)
return [ChangeProposalRead.model_validate(r) for r in rows]
@router.post(
"/{tree_id}/proposals", response_model=ChangeProposalRead, status_code=status.HTTP_201_CREATED
)
async def create_proposal(
tree_id: uuid.UUID, data: ChangeProposalCreate, session: SessionDep, current: CurrentUser
) -> ChangeProposalRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
operations = [op.model_dump(mode="json") for op in data.operations]
cp = await change_proposal_service.propose(
session,
tree=tree,
origin=data.origin,
created_by=current.id,
summary=data.summary,
rationale=data.rationale,
operations=operations,
)
return ChangeProposalRead.model_validate(cp)
@router.get("/{tree_id}/proposals/{proposal_id}", response_model=ChangeProposalRead)
async def get_proposal(
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> ChangeProposalRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
cp = await change_proposal_service.get_proposal(
session, viewer_id=current.id, tree=tree, proposal_id=proposal_id
)
return ChangeProposalRead.model_validate(cp)
@router.post("/{tree_id}/proposals/{proposal_id}/apply", response_model=ChangeProposalRead)
async def apply_proposal(
tree_id: uuid.UUID,
proposal_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
data: ProposalReview | None = None,
) -> ChangeProposalRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
edited = (
[op.model_dump(mode="json") for op in data.operations]
if data and data.operations is not None
else None
)
cp = await change_proposal_service.apply(
session, actor=current, tree=tree, proposal_id=proposal_id, edited_operations=edited
)
return ChangeProposalRead.model_validate(cp)
@router.post("/{tree_id}/proposals/{proposal_id}/reject", response_model=ChangeProposalRead)
async def reject_proposal(
tree_id: uuid.UUID,
proposal_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
data: ProposalReview | None = None,
) -> ChangeProposalRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
cp = await change_proposal_service.reject(
session,
actor=current,
tree=tree,
proposal_id=proposal_id,
note=data.note if data else None,
)
return ChangeProposalRead.model_validate(cp)
@router.delete(
"/{tree_id}/proposals/{proposal_id}", status_code=status.HTTP_204_NO_CONTENT
)
async def delete_proposal(
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await change_proposal_service.delete_proposal(
session, actor=current, tree=tree, proposal_id=proposal_id
)