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:
Vendored
+352
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user