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>
45 lines
1.2 KiB
Python
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
|