diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 5f17970..626f707 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/proposals.py b/backend/app/api/v1/proposals.py new file mode 100644 index 0000000..9af774c --- /dev/null +++ b/backend/app/api/v1/proposals.py @@ -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 + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9d17967..e8cbadd 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/change_proposal.py b/backend/app/models/change_proposal.py new file mode 100644 index 0000000..81b2c1f --- /dev/null +++ b/backend/app/models/change_proposal.py @@ -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) diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 4069e5a..390e6da 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -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 diff --git a/backend/app/schemas/change_proposal.py b/backend/app/schemas/change_proposal.py new file mode 100644 index 0000000..d7b12b7 --- /dev/null +++ b/backend/app/schemas/change_proposal.py @@ -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 diff --git a/backend/app/services/change_proposal_service.py b/backend/app/services/change_proposal_service.py new file mode 100644 index 0000000..9667930 --- /dev/null +++ b/backend/app/services/change_proposal_service.py @@ -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}'") diff --git a/backend/migrations/versions/a1b2c3d4e5f6_change_proposals.py b/backend/migrations/versions/a1b2c3d4e5f6_change_proposals.py new file mode 100644 index 0000000..b9339ed --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_change_proposals.py @@ -0,0 +1,62 @@ +"""change_proposals (AI propose-then-confirm) + +Revision ID: a1b2c3d4e5f6 +Revises: d4a9c1e7b2f3 +Create Date: 2026-06-09 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "a1b2c3d4e5f6" +down_revision: str | None = "d4a9c1e7b2f3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "change_proposals", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("tree_id", sa.Uuid(), nullable=False), + sa.Column( + "status", + sa.Enum("pending", "applied", "rejected", name="change_proposal_status"), + server_default="pending", + nullable=False, + ), + sa.Column( + "origin", + sa.Enum("assistant", "contributor", name="change_proposal_origin"), + server_default="assistant", + nullable=False, + ), + sa.Column("created_by_user_id", sa.Uuid(), nullable=True), + sa.Column("summary", sa.String(length=512), nullable=False), + sa.Column("rationale", sa.Text(), nullable=True), + sa.Column("operations", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("reviewed_by_user_id", sa.Uuid(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("review_note", sa.String(length=512), nullable=True), + sa.Column("apply_error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["tree_id"], ["trees.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_change_proposals_tree_id", "change_proposals", ["tree_id"]) + op.create_index("ix_change_proposals_status", "change_proposals", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_change_proposals_status", table_name="change_proposals") + op.drop_index("ix_change_proposals_tree_id", table_name="change_proposals") + op.drop_table("change_proposals") + sa.Enum(name="change_proposal_status").drop(op.get_bind()) + sa.Enum(name="change_proposal_origin").drop(op.get_bind()) diff --git a/backend/tests/test_change_proposal.py b/backend/tests/test_change_proposal.py new file mode 100644 index 0000000..afa1bb5 --- /dev/null +++ b/backend/tests/test_change_proposal.py @@ -0,0 +1,154 @@ +"""ChangeProposal: a proposal mutates nothing until an editor approves it, and +application goes through the editing services (privacy + audit). See +docs/design/change-proposal.md and CLAUDE.md non-negotiable #1. +""" + +import uuid + +from tests.conftest import auth, register + + +async def _tree(client, email): + h = auth(await register(client, email)) + tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"] + return h, tid + + +async def _propose(client, tid, headers, summary, operations, origin="assistant"): + r = await client.post( + f"/api/v1/trees/{tid}/proposals", + json={"summary": summary, "origin": origin, "operations": operations}, + headers=headers, + ) + assert r.status_code == 201, r.text + return r.json() + + +async def test_proposal_not_applied_until_approved(client): + h, tid = await _tree(client, "cp-owner@ex.com") + cp = await _propose( + client, + tid, + h, + "Add Ada Lovelace", + [{"op": "create", "entity_type": "person", "payload": {"given": "Ada", "surname": "Lovelace"}}], + ) + assert cp["status"] == "pending" + + # The proposed person does NOT exist yet. + people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json() + assert not any(p["primary_name"] == "Ada Lovelace" for p in people) + + # Approve → applied → the person now exists. + a = await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h) + assert a.status_code == 200 and a.json()["status"] == "applied" + people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json() + assert any(p["primary_name"] == "Ada Lovelace" for p in people) + + +async def test_reject_does_not_apply(client): + h, tid = await _tree(client, "cp-reject@ex.com") + cp = await _propose( + client, + tid, + h, + "Add Reject Me", + [{"op": "create", "entity_type": "person", "payload": {"given": "Reject", "surname": "Me"}}], + ) + rr = await client.post( + f"/api/v1/trees/{tid}/proposals/{cp['id']}/reject", json={"note": "no"}, headers=h + ) + assert rr.status_code == 200 and rr.json()["status"] == "rejected" + people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json() + assert not any(p["primary_name"] == "Reject Me" for p in people) + # A rejected proposal can't then be applied. + assert ( + await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h) + ).status_code == 409 + + +async def test_non_editor_member_can_see_but_not_apply(client): + owner = auth(await register(client, "cp-o2@ex.com")) + viewer = auth(await register(client, "cp-v2@ex.com")) + tid = ( + await client.post("/api/v1/trees", json={"name": "Shared"}, headers=owner) + ).json()["id"] + await client.post( + f"/api/v1/trees/{tid}/members", json={"email": "cp-v2@ex.com", "role": "viewer"}, headers=owner + ) + cp = await _propose( + client, + tid, + owner, + "Add V P", + [{"op": "create", "entity_type": "person", "payload": {"given": "V", "surname": "P"}}], + ) + # A viewer (member) can see the proposal list... + assert (await client.get(f"/api/v1/trees/{tid}/proposals", headers=viewer)).status_code == 200 + # ...but cannot apply it (not an editor). + assert ( + await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=viewer) + ).status_code == 403 + + +async def test_multi_op_applies_all(client): + h, tid = await _tree(client, "cp-multi@ex.com") + pid = ( + await client.post( + f"/api/v1/trees/{tid}/persons", json={"given": "Multi", "surname": "Op"}, headers=h + ) + ).json()["id"] + cp = await _propose( + client, + tid, + h, + "name + event on existing person", + [ + {"op": "create", "entity_type": "name", "payload": {"person_id": pid, "name_type": "alias", "given": "Mo"}}, + {"op": "create", "entity_type": "event", "payload": {"event_type": "birth", "person_id": pid, "date_value": "1900"}}, + ], + ) + assert ( + await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h) + ).status_code == 200 + names = (await client.get(f"/api/v1/trees/{tid}/persons/{pid}/names", headers=h)).json() + assert any(n.get("given") == "Mo" for n in names) + events = (await client.get(f"/api/v1/trees/{tid}/persons/{pid}/events", headers=h)).json() + assert any(e["date_value"] == "1900" for e in events) + + +async def test_apply_with_edited_operations(client): + h, tid = await _tree(client, "cp-edit@ex.com") + cp = await _propose( + client, + tid, + h, + "Add Original", + [{"op": "create", "entity_type": "person", "payload": {"given": "Original", "surname": "Name"}}], + ) + edited = { + "operations": [ + {"op": "create", "entity_type": "person", "payload": {"given": "Edited", "surname": "Name"}} + ] + } + assert ( + await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", json=edited, headers=h) + ).status_code == 200 + names = {p["primary_name"] for p in (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()} + assert "Edited Name" in names and "Original Name" not in names + + +async def test_apply_error_keeps_pending(client): + h, tid = await _tree(client, "cp-err@ex.com") + cp = await _propose( + client, + tid, + h, + "Bad update", + [{"op": "update", "entity_type": "person", "entity_id": str(uuid.uuid4()), "payload": {"given": "X"}}], + ) + a = await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h) + assert a.status_code == 409 + g = (await client.get(f"/api/v1/trees/{tid}/proposals/{cp['id']}", headers=h)).json() + assert g["status"] == "pending" + assert g["apply_error"] diff --git a/docs/design/change-proposal.md b/docs/design/change-proposal.md new file mode 100644 index 0000000..c24f85c --- /dev/null +++ b/docs/design/change-proposal.md @@ -0,0 +1,80 @@ +# Design note: ChangeProposal (propose-then-confirm) + +Status: **in progress**. Implements non-negotiable #1 (CLAUDE.md): *the AI +assistant never writes autonomously.* Every assistant "write" emits a +**ChangeProposal** — a structured diff a human approves, edits, or rejects. + +## The invariant, structurally + +There must be **no code path where a model response mutates tree data**. We get +this by construction, not convention: + +- Model providers (`app/integrations/models/*`) are read-only text/vector + producers — they never import a repository or session-mutating service. +- The assistant's tools, when they land, will call `change_proposal_service.propose(...)`, + which only **inserts a pending ChangeProposal**. It performs no domain mutation. +- A ChangeProposal's operations are executed **only** by + `change_proposal_service.apply(...)`, which: + 1. requires the actor be an **editor/owner** of the tree (`privacy.can_edit_tree`), + 2. dispatches each operation through the **normal editing services** + (`person_service`, `event_service`, …) — so every change passes the privacy + engine and writes an `AuditEntry` with the **human** as `actor`, + 3. flips the proposal to `applied`. + +So an assistant can *suggest* anything, but a change reaches the database only +when a human with edit rights approves it, and only via the same services a human +edit uses. + +## Data model + +`ChangeProposal` (`TenantScoped` tree_id, `Timestamps`, `SoftDelete`): + +| field | notes | +|---|---| +| `tree_id` | tenant boundary | +| `status` | `pending` \| `applied` \| `rejected` | +| `origin` | `assistant` \| `contributor` — who proposed it (the contributor case also moderates untrusted human edits) | +| `created_by_user_id` | the user on whose behalf the assistant acted, or the contributor | +| `summary` | one-line human description ("Add birth 1850 to John Smith") | +| `rationale` | the assistant's reasoning / sources (text) | +| `operations` | JSONB list of ops (the structured diff) | +| `reviewed_by_user_id`, `reviewed_at`, `review_note` | set on approve/reject | +| `apply_error` | populated if application failed (proposal stays `pending`) | + +An **operation** is `{op, entity_type, entity_id?, payload}`: +- `op` ∈ `create` | `update` | `delete` +- `entity_type` ∈ `person` | `name` | `event` | `relationship` | `source` | `citation` +- `entity_id` — null for `create`; the target id for `update`/`delete` +- `payload` — proposed field values (`create`/`update`); ignored for `delete` + +A proposal may carry several operations (e.g. "add a person and link them as a +child" = create person + create relationship), applied **in order**. The editing +services each commit, so v1 application is **not transactional across ops** — if +op N fails, ops 1..N-1 are already applied and the proposal stays `pending` with +`apply_error` set so the reviewer can fix and re-apply the remainder. Single-op +proposals (the common near-term case) are effectively atomic. Cross-op atomicity +is a follow-up (it needs the services to accept a no-commit mode). + +## Service surface + +- `propose(session, *, tree, origin, created_by, summary, rationale, operations) -> ChangeProposal` + — inserts a `pending` proposal. The **only** thing the assistant can call. +- `list_proposals` / `get_proposal` — visible to tree members. +- `apply(session, *, actor, tree, proposal_id, edited_operations=None) -> ChangeProposal` + — editor-only. Optional `edited_operations` lets the reviewer tweak the diff + before applying ("edit" in approve/edit/reject). Dispatches each op through the + editing services; on any failure, rolls back and records `apply_error`. +- `reject(session, *, actor, tree, proposal_id, note=None)` — editor-only. + +## API + +`/trees/{id}/proposals`: `GET` (list, `?status=`), `POST` (create — used by tests +and the future contributor flow), `GET /{pid}`, `POST /{pid}/apply`, +`POST /{pid}/reject`, `DELETE /{pid}`. + +## Out of scope (follow-ups) + +- The assistant itself (it will be the primary producer; #-future). +- A rich diff/edit UI — v1 ships a review list with approve/reject; "edit before + apply" is supported in the API and can get UI later. +- Dispatch for media/place/tree-settings ops (added when a producer needs them). diff --git a/frontend/app/trees/[id]/proposals/page.tsx b/frontend/app/trees/[id]/proposals/page.tsx new file mode 100644 index 0000000..809c2cf --- /dev/null +++ b/frontend/app/trees/[id]/proposals/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type Proposal = components["schemas"]["ChangeProposalRead"]; +type Op = { op: string; entity_type: string; entity_id?: string | null; payload?: Record }; + +const STATUS_STYLE: Record = { + pending: "border-bronze/40 text-bronze", + applied: "border-green-600/40 text-green-700 dark:text-green-400", + rejected: "border-[var(--border)] text-[var(--muted)]", +}; + +export default function ProposalsPage() { + const { id: treeId } = useParams<{ id: string }>(); + const [proposals, setProposals] = useState([]); + const [meRole, setMeRole] = useState(null); + const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(null); + + const load = useCallback(async () => { + const [props, me, members] = await Promise.all([ + api.GET("/api/v1/trees/{tree_id}/proposals", { params: { path: { tree_id: treeId } } }), + api.GET("/api/v1/users/me"), + api.GET("/api/v1/trees/{tree_id}/members", { params: { path: { tree_id: treeId } } }), + ]); + const myId = me.data?.id; + setMeRole((members.data ?? []).find((m) => m.user_id === myId)?.role ?? null); + setProposals(props.data ?? []); + setReady(true); + }, [treeId]); + + useEffect(() => { + load(); + }, [load]); + + const canReview = meRole === "owner" || meRole === "editor"; + + async function act(pid: string, action: "apply" | "reject") { + setBusy(pid); + const { error, response } = await api.POST( + `/api/v1/trees/{tree_id}/proposals/{proposal_id}/${action}` as "/api/v1/trees/{tree_id}/proposals/{proposal_id}/apply", + { params: { path: { tree_id: treeId, proposal_id: pid } } }, + ); + setBusy(null); + if (error) { + // 409 = couldn't apply (e.g. references a missing record); reload shows apply_error. + if (response.status !== 409) alert("Action failed."); + } + load(); + } + + if (!ready) return

Loading…

; + + return ( +
+
+

Change proposals

+

+ Suggested edits (from the assistant or contributors). Nothing changes until you approve — + approving applies it as your own edit, through the normal checks and audit log. +

+
+ + {proposals.length === 0 ? ( +

No proposals.

+ ) : ( +
    + {proposals.map((p) => { + const ops = (p.operations as Op[]) ?? []; + return ( +
  • + + +
    +
    +
    {p.summary}
    +
    + {p.origin} · {ops.length} change{ops.length === 1 ? "" : "s"} +
    +
    + + {p.status} + +
    + + {p.rationale &&

    {p.rationale}

    } + +
      + {ops.map((o, i) => ( +
    • + {o.op}{" "} + {o.entity_type} + {o.payload && Object.keys(o.payload).length > 0 && ( + + {" "} + ·{" "} + {Object.entries(o.payload) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(", ")} + + )} +
    • + ))} +
    + + {p.apply_error && ( +

    + Couldn’t apply: {p.apply_error} +

    + )} + + {p.status === "pending" && canReview && ( +
    + + +
    + )} + {p.status === "pending" && !canReview && ( +

    Only an editor can approve this.

    + )} +
    +
    +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index ea42f0d..676a13f 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import { Archive, ArrowDownUp, BookText, + ClipboardCheck, Compass, FolderTree, Image as ImageIcon, @@ -138,6 +139,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { icon={Sparkles} active={pathname.startsWith(`/trees/${treeId}/cleanup`)} /> + ; export interface components { @@ -1013,6 +1083,63 @@ export interface components { /** Source Id */ source_id?: string | null; }; + /** ChangeProposalCreate */ + ChangeProposalCreate: { + /** Summary */ + summary: string; + /** Rationale */ + rationale?: string | null; + /** @default contributor */ + origin?: components["schemas"]["ChangeProposalOrigin"]; + /** Operations */ + operations: components["schemas"]["ProposalOperation"][]; + }; + /** + * ChangeProposalOrigin + * @enum {string} + */ + ChangeProposalOrigin: "assistant" | "contributor"; + /** ChangeProposalRead */ + ChangeProposalRead: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Tree Id + * Format: uuid + */ + tree_id: string; + status: components["schemas"]["ChangeProposalStatus"]; + origin: components["schemas"]["ChangeProposalOrigin"]; + /** Created By User Id */ + created_by_user_id: string | null; + /** Summary */ + summary: string; + /** Rationale */ + rationale: string | null; + /** Operations */ + operations: unknown[]; + /** Reviewed By User Id */ + reviewed_by_user_id: string | null; + /** Reviewed At */ + reviewed_at: string | null; + /** Review Note */ + review_note: string | null; + /** Apply Error */ + apply_error: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** + * ChangeProposalStatus + * @enum {string} + */ + ChangeProposalStatus: "pending" | "applied" | "rejected"; /** * CitationConfidence * @enum {string} @@ -1560,6 +1687,29 @@ export interface components { /** Notes */ notes?: string | null; }; + /** ProposalOperation */ + ProposalOperation: { + /** Op */ + op: string; + /** Entity Type */ + entity_type: string; + /** Entity Id */ + entity_id?: string | null; + /** + * Payload + * @default {} + */ + payload?: { + [key: string]: unknown; + }; + }; + /** ProposalReview */ + ProposalReview: { + /** Note */ + note?: string | null; + /** Operations */ + operations?: components["schemas"]["ProposalOperation"][] | null; + }; /** * PublicTreeRead * @description Tree projection for the public surface — deliberately omits owner_id so a @@ -4299,4 +4449,206 @@ export interface operations { }; }; }; + list_proposals_api_v1_trees__tree_id__proposals_get: { + parameters: { + query?: { + status?: components["schemas"]["ChangeProposalStatus"] | null; + }; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChangeProposalRead"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + create_proposal_api_v1_trees__tree_id__proposals_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangeProposalCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChangeProposalRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_proposal_api_v1_trees__tree_id__proposals__proposal_id__get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + proposal_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChangeProposalRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_proposal_api_v1_trees__tree_id__proposals__proposal_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + proposal_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + apply_proposal_api_v1_trees__tree_id__proposals__proposal_id__apply_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + proposal_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProposalReview"] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChangeProposalRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reject_proposal_api_v1_trees__tree_id__proposals__proposal_id__reject_post: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + proposal_id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ProposalReview"] | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChangeProposalRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index 9c67e34..b94e4a7 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -3742,6 +3742,357 @@ } } } + }, + "/api/v1/trees/{tree_id}/proposals": { + "get": { + "tags": [ + "proposals" + ], + "summary": "List Proposals", + "operationId": "list_proposals_api_v1_trees__tree_id__proposals_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChangeProposalStatus" + }, + { + "type": "null" + } + ], + "title": "Status" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChangeProposalRead" + }, + "title": "Response List Proposals Api V1 Trees Tree Id Proposals Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "proposals" + ], + "summary": "Create Proposal", + "operationId": "create_proposal_api_v1_trees__tree_id__proposals_post", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeProposalCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeProposalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/trees/{tree_id}/proposals/{proposal_id}": { + "get": { + "tags": [ + "proposals" + ], + "summary": "Get Proposal", + "operationId": "get_proposal_api_v1_trees__tree_id__proposals__proposal_id__get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "proposal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Proposal Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeProposalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "proposals" + ], + "summary": "Delete Proposal", + "operationId": "delete_proposal_api_v1_trees__tree_id__proposals__proposal_id__delete", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "proposal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Proposal Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/trees/{tree_id}/proposals/{proposal_id}/apply": { + "post": { + "tags": [ + "proposals" + ], + "summary": "Apply Proposal", + "operationId": "apply_proposal_api_v1_trees__tree_id__proposals__proposal_id__apply_post", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "proposal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Proposal Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProposalReview" + }, + { + "type": "null" + } + ], + "title": "Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeProposalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/trees/{tree_id}/proposals/{proposal_id}/reject": { + "post": { + "tags": [ + "proposals" + ], + "summary": "Reject Proposal", + "operationId": "reject_proposal_api_v1_trees__tree_id__proposals__proposal_id__reject_post", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + }, + { + "name": "proposal_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Proposal Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProposalReview" + }, + { + "type": "null" + } + ], + "title": "Data" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeProposalRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -3886,6 +4237,179 @@ ], "title": "Body_upload_media_api_v1_trees__tree_id__media_post" }, + "ChangeProposalCreate": { + "properties": { + "summary": { + "type": "string", + "title": "Summary" + }, + "rationale": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rationale" + }, + "origin": { + "$ref": "#/components/schemas/ChangeProposalOrigin", + "default": "contributor" + }, + "operations": { + "items": { + "$ref": "#/components/schemas/ProposalOperation" + }, + "type": "array", + "title": "Operations" + } + }, + "type": "object", + "required": [ + "summary", + "operations" + ], + "title": "ChangeProposalCreate" + }, + "ChangeProposalOrigin": { + "type": "string", + "enum": [ + "assistant", + "contributor" + ], + "title": "ChangeProposalOrigin" + }, + "ChangeProposalRead": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "tree_id": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + }, + "status": { + "$ref": "#/components/schemas/ChangeProposalStatus" + }, + "origin": { + "$ref": "#/components/schemas/ChangeProposalOrigin" + }, + "created_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Created By User Id" + }, + "summary": { + "type": "string", + "title": "Summary" + }, + "rationale": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Rationale" + }, + "operations": { + "items": {}, + "type": "array", + "title": "Operations" + }, + "reviewed_by_user_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Reviewed By User Id" + }, + "reviewed_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Reviewed At" + }, + "review_note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Review Note" + }, + "apply_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Apply Error" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "tree_id", + "status", + "origin", + "created_by_user_id", + "summary", + "rationale", + "operations", + "reviewed_by_user_id", + "reviewed_at", + "review_note", + "apply_error", + "created_at" + ], + "title": "ChangeProposalRead" + }, + "ChangeProposalStatus": { + "type": "string", + "enum": [ + "pending", + "applied", + "rejected" + ], + "title": "ChangeProposalStatus" + }, "CitationConfidence": { "type": "string", "enum": [ @@ -5662,6 +6186,73 @@ "type": "object", "title": "PersonUpdate" }, + "ProposalOperation": { + "properties": { + "op": { + "type": "string", + "title": "Op" + }, + "entity_type": { + "type": "string", + "title": "Entity Type" + }, + "entity_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Entity Id" + }, + "payload": { + "additionalProperties": true, + "type": "object", + "title": "Payload", + "default": {} + } + }, + "type": "object", + "required": [ + "op", + "entity_type" + ], + "title": "ProposalOperation" + }, + "ProposalReview": { + "properties": { + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "operations": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ProposalOperation" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Operations" + } + }, + "type": "object", + "title": "ProposalReview" + }, "PublicTreeRead": { "properties": { "id": {