Files
provenance/backend/app/schemas/change_proposal.py
T
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

45 lines
1.2 KiB
Python

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