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>
49 lines
2.1 KiB
Python
49 lines
2.1 KiB
Python
"""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)
|