abaa8efdd5
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>
356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""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}'")
|