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>
This commit is contained in:
2026-06-09 15:44:40 -04:00
parent 251a10a087
commit abaa8efdd5
14 changed files with 1974 additions and 0 deletions
+2
View File
@@ -12,6 +12,7 @@ from app.api.v1 import (
members,
names,
persons,
proposals,
public,
relationships,
sources,
@@ -34,3 +35,4 @@ api_router.include_router(gedcom.router)
api_router.include_router(cleanup.router)
api_router.include_router(public.router)
api_router.include_router(members.router)
api_router.include_router(proposals.router)
+116
View File
@@ -0,0 +1,116 @@
"""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
)
+2
View File
@@ -4,6 +4,7 @@ and for ``create_all`` in tests."""
from app.models.audit import AuditEntry
from app.models.auth import Session, UserToken
from app.models.base import Base
from app.models.change_proposal import ChangeProposal
from app.models.event import Event
from app.models.media import Media
from app.models.person import Name, Person
@@ -30,4 +31,5 @@ __all__ = [
"Session",
"UserToken",
"Media",
"ChangeProposal",
]
+48
View File
@@ -0,0 +1,48 @@
"""ChangeProposal — a structured diff the AI assistant (or an untrusted
contributor) proposes, which a human approves/edits/rejects. Applying it routes
each operation through the normal editing services, so the change passes the
privacy engine and is audited as the approving human's action. See
docs/design/change-proposal.md and CLAUDE.md non-negotiable #1.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.enums import ChangeProposalOrigin, ChangeProposalStatus
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class ChangeProposal(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "change_proposals"
status: Mapped[ChangeProposalStatus] = mapped_column(
SAEnum(ChangeProposalStatus, name="change_proposal_status"),
default=ChangeProposalStatus.pending,
server_default=ChangeProposalStatus.pending.value,
index=True,
)
origin: Mapped[ChangeProposalOrigin] = mapped_column(
SAEnum(ChangeProposalOrigin, name="change_proposal_origin"),
default=ChangeProposalOrigin.assistant,
server_default=ChangeProposalOrigin.assistant.value,
)
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
summary: Mapped[str] = mapped_column(String(512))
rationale: Mapped[str | None] = mapped_column(Text)
# The structured diff: a list of {op, entity_type, entity_id?, payload} dicts.
operations: Mapped[list] = mapped_column(JSONB, nullable=False)
reviewed_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
review_note: Mapped[str | None] = mapped_column(String(512))
apply_error: Mapped[str | None] = mapped_column(Text)
+11
View File
@@ -61,3 +61,14 @@ class AuditActorType(enum.StrEnum):
class TokenPurpose(enum.StrEnum):
email_verify = "email_verify"
password_reset = "password_reset"
class ChangeProposalStatus(enum.StrEnum):
pending = "pending"
applied = "applied"
rejected = "rejected"
class ChangeProposalOrigin(enum.StrEnum):
assistant = "assistant" # the AI assistant, acting on behalf of a user
contributor = "contributor" # an untrusted human edit awaiting moderation
+44
View File
@@ -0,0 +1,44 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import ChangeProposalOrigin, ChangeProposalStatus
class ProposalOperation(BaseModel):
op: str # create | update | delete
entity_type: str # person | name | event | relationship | source | citation
entity_id: uuid.UUID | None = None
payload: dict = {}
class ChangeProposalCreate(BaseModel):
summary: str
rationale: str | None = None
origin: ChangeProposalOrigin = ChangeProposalOrigin.contributor
operations: list[ProposalOperation]
class ProposalReview(BaseModel):
note: str | None = None
# Optional edited operations to apply instead of the original (approve-with-edits).
operations: list[ProposalOperation] | None = None
class ChangeProposalRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
status: ChangeProposalStatus
origin: ChangeProposalOrigin
created_by_user_id: uuid.UUID | None
summary: str
rationale: str | None
operations: list
reviewed_by_user_id: uuid.UUID | None
reviewed_at: datetime | None
review_note: str | None
apply_error: str | None
created_at: datetime
@@ -0,0 +1,355 @@
"""ChangeProposal lifecycle: propose (assistant/contributor) → review → apply/reject.
The structural guarantee (CLAUDE.md #1): a proposal's operations are executed
ONLY by ``apply()``, which requires the actor be an editor and dispatches every
op through the normal editing services — so each change passes the privacy
engine and is audited as the approving human. ``propose()`` only inserts a
pending row; it performs no domain mutation. See docs/design/change-proposal.md.
"""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.change_proposal import ChangeProposal
from app.models.enums import (
ChangeProposalOrigin,
ChangeProposalStatus,
CitationConfidence,
ParentChildQualifier,
RelationshipType,
)
from app.models.tree import Tree
from app.models.user import User
from app.services import (
citation_service,
event_service,
name_service,
person_service,
privacy,
relationship_service,
source_service,
)
from app.services.exceptions import Conflict, Forbidden, NotFound
def _now() -> datetime:
return datetime.now(UTC)
def _uuid(v) -> uuid.UUID | None:
return uuid.UUID(str(v)) if v else None
async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
async def _require_member(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> None:
# Proposals can reference unredacted facts → members only.
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
raise Forbidden("only members can see change proposals")
async def _load(
session: AsyncSession, tree: Tree, proposal_id: uuid.UUID
) -> ChangeProposal:
cp = (
await session.execute(
select(ChangeProposal).where(
ChangeProposal.id == proposal_id,
ChangeProposal.tree_id == tree.id,
ChangeProposal.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if cp is None:
raise NotFound("proposal not found")
return cp
async def propose(
session: AsyncSession,
*,
tree: Tree,
origin: ChangeProposalOrigin,
created_by: uuid.UUID | None,
summary: str,
rationale: str | None,
operations: list[dict],
) -> ChangeProposal:
"""Insert a pending proposal. The ONLY mutation here is the proposal row — no
tree data changes. (No edit-rights check: proposing isn't writing.)"""
cp = ChangeProposal(
tree_id=tree.id,
origin=origin,
created_by_user_id=created_by,
summary=summary,
rationale=rationale,
operations=operations,
status=ChangeProposalStatus.pending,
)
session.add(cp)
await session.commit()
await session.refresh(cp)
return cp
async def list_proposals(
session: AsyncSession,
*,
viewer_id: uuid.UUID,
tree: Tree,
status: ChangeProposalStatus | None = None,
) -> list[ChangeProposal]:
await _require_member(session, viewer_id=viewer_id, tree=tree)
stmt = select(ChangeProposal).where(
ChangeProposal.tree_id == tree.id, ChangeProposal.deleted_at.is_(None)
)
if status is not None:
stmt = stmt.where(ChangeProposal.status == status)
stmt = stmt.order_by(ChangeProposal.created_at.desc())
return list((await session.execute(stmt)).scalars().all())
async def get_proposal(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, proposal_id: uuid.UUID
) -> ChangeProposal:
await _require_member(session, viewer_id=viewer_id, tree=tree)
return await _load(session, tree, proposal_id)
async def reject(
session: AsyncSession,
*,
actor: User,
tree: Tree,
proposal_id: uuid.UUID,
note: str | None = None,
) -> ChangeProposal:
await _require_editor(session, actor=actor, tree=tree)
cp = await _load(session, tree, proposal_id)
if cp.status is not ChangeProposalStatus.pending:
raise Conflict("proposal is not pending")
cp.status = ChangeProposalStatus.rejected
cp.reviewed_by_user_id = actor.id
cp.reviewed_at = _now()
cp.review_note = note
await session.commit()
await session.refresh(cp)
return cp
async def apply(
session: AsyncSession,
*,
actor: User,
tree: Tree,
proposal_id: uuid.UUID,
edited_operations: list[dict] | None = None,
) -> ChangeProposal:
await _require_editor(session, actor=actor, tree=tree)
cp = await _load(session, tree, proposal_id)
if cp.status is not ChangeProposalStatus.pending:
raise Conflict("proposal is not pending")
ops = edited_operations if edited_operations is not None else list(cp.operations)
try:
for op in ops:
await _dispatch(session, actor=actor, tree=tree, op=op)
except Conflict:
raise
except Exception as exc: # noqa: BLE001 — record the failure on the proposal
err = f"{type(exc).__name__}: {exc}"[:2000]
# The editing services raise (NotFound/Forbidden/validation) before
# committing, so the transaction is clean — record the error and commit.
# If a later op did write before failing, those ops already committed
# (v1 isn't cross-op transactional; see the design note).
cp = await _load(session, tree, proposal_id)
cp.apply_error = err
await session.commit()
raise Conflict(f"could not apply proposal: {err}") from exc
if edited_operations is not None:
cp.operations = edited_operations
cp.status = ChangeProposalStatus.applied
cp.reviewed_by_user_id = actor.id
cp.reviewed_at = _now()
cp.apply_error = None
await session.commit()
await session.refresh(cp)
return cp
async def delete_proposal(
session: AsyncSession, *, actor: User, tree: Tree, proposal_id: uuid.UUID
) -> None:
await _require_editor(session, actor=actor, tree=tree)
cp = await _load(session, tree, proposal_id)
cp.deleted_at = _now()
await session.commit()
def _bad(entity_type: str, action: str) -> Conflict:
return Conflict(f"unsupported operation '{action}' on '{entity_type}'")
async def _dispatch(session: AsyncSession, *, actor: User, tree: Tree, op: dict) -> None:
"""Route one operation through the matching editing service (privacy + audit)."""
et = op.get("entity_type")
action = op.get("op")
payload = op.get("payload") or {}
eid = op.get("entity_id")
if et == "person":
if action == "create":
await person_service.create_person(
session,
actor=actor,
tree=tree,
given=payload.get("given"),
surname=payload.get("surname"),
gender=payload.get("gender"),
is_living=payload.get("is_living"),
notes=payload.get("notes"),
)
elif action == "update":
await person_service.update_person(
session, actor=actor, tree=tree, person_id=_uuid(eid), changes=payload
)
elif action == "delete":
await person_service.delete_person(
session,
actor=actor,
tree=tree,
person_id=_uuid(eid),
cascade=bool(payload.get("cascade", False)),
)
else:
raise _bad(et, action)
elif et == "event":
if action == "create":
await event_service.create_event(
session,
actor=actor,
tree=tree,
event_type=payload["event_type"],
person_id=_uuid(payload.get("person_id")),
relationship_id=_uuid(payload.get("relationship_id")),
date_value=payload.get("date_value"),
date_precision=payload.get("date_precision"),
detail=payload.get("detail"),
notes=payload.get("notes"),
)
elif action == "update":
await event_service.update_event(
session, actor=actor, tree=tree, event_id=_uuid(eid), changes=payload
)
elif action == "delete":
await event_service.delete_event(
session, actor=actor, tree=tree, event_id=_uuid(eid)
)
else:
raise _bad(et, action)
elif et == "relationship":
if action == "create":
await relationship_service.create_relationship(
session,
actor=actor,
tree=tree,
type=RelationshipType(payload["type"]),
person_from_id=_uuid(payload["person_from_id"]),
person_to_id=_uuid(payload["person_to_id"]),
qualifier=ParentChildQualifier(payload["qualifier"])
if payload.get("qualifier")
else None,
notes=payload.get("notes"),
)
elif action == "delete":
await relationship_service.delete_relationship(
session, actor=actor, tree=tree, relationship_id=_uuid(eid)
)
else:
raise _bad(et, action)
elif et == "name":
if action == "create":
await name_service.create_name(
session,
actor=actor,
tree=tree,
person_id=_uuid(payload["person_id"]),
name_type=payload.get("name_type", "birth"),
given=payload.get("given"),
surname=payload.get("surname"),
prefix=payload.get("prefix"),
suffix=payload.get("suffix"),
nickname=payload.get("nickname"),
is_primary=bool(payload.get("is_primary", False)),
)
elif action == "update":
changes = {k: v for k, v in payload.items() if k != "person_id"}
await name_service.update_name(
session,
actor=actor,
tree=tree,
person_id=_uuid(payload["person_id"]),
name_id=_uuid(eid),
changes=changes,
)
elif action == "delete":
await name_service.delete_name(
session,
actor=actor,
tree=tree,
person_id=_uuid(payload["person_id"]),
name_id=_uuid(eid),
)
else:
raise _bad(et, action)
elif et == "source":
if action == "create":
await source_service.create_source(
session,
actor=actor,
tree=tree,
title=payload["title"],
author=payload.get("author"),
source_type=payload.get("source_type"),
repository=payload.get("repository"),
url=payload.get("url"),
citation_text=payload.get("citation_text"),
publication_info=payload.get("publication_info"),
quality_note=payload.get("quality_note"),
)
elif action == "delete":
await source_service.delete_source(
session, actor=actor, tree=tree, source_id=_uuid(eid)
)
else:
raise _bad(et, action)
elif et == "citation":
if action == "create":
await citation_service.create_citation(
session,
actor=actor,
tree=tree,
source_id=_uuid(payload["source_id"]),
person_id=_uuid(payload.get("person_id")),
event_id=_uuid(payload.get("event_id")),
name_id=_uuid(payload.get("name_id")),
relationship_id=_uuid(payload.get("relationship_id")),
page=payload.get("page"),
detail=payload.get("detail"),
confidence=CitationConfidence(payload["confidence"])
if payload.get("confidence")
else None,
)
elif action == "delete":
await citation_service.delete_citation(
session, actor=actor, tree=tree, citation_id=_uuid(eid)
)
else:
raise _bad(et, action)
else:
raise Conflict(f"unsupported entity type '{et}'")