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
+352
View File
@@ -961,6 +961,76 @@ export interface paths {
patch: operations["update_member_api_v1_trees__tree_id__members__membership_id__patch"];
trace?: never;
};
"/api/v1/trees/{tree_id}/proposals": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Proposals */
get: operations["list_proposals_api_v1_trees__tree_id__proposals_get"];
put?: never;
/** Create Proposal */
post: operations["create_proposal_api_v1_trees__tree_id__proposals_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/proposals/{proposal_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Proposal */
get: operations["get_proposal_api_v1_trees__tree_id__proposals__proposal_id__get"];
put?: never;
post?: never;
/** Delete Proposal */
delete: operations["delete_proposal_api_v1_trees__tree_id__proposals__proposal_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/proposals/{proposal_id}/apply": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Apply Proposal */
post: operations["apply_proposal_api_v1_trees__tree_id__proposals__proposal_id__apply_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/proposals/{proposal_id}/reject": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Reject Proposal */
post: operations["reject_proposal_api_v1_trees__tree_id__proposals__proposal_id__reject_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
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"];
};
};
};
};
}