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>
This commit is contained in:
2026-06-09 15:44:40 -04:00
parent 251a10a087
commit abaa8efdd5
14 changed files with 1974 additions and 0 deletions
+591
View File
@@ -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": {