Proposals
A proposal is an ops envelope submitted for human review instead of being applied. Nothing about the scene changes when a proposal is created; when a workspace member approves it, the ops land as a normal revision carrying full provenance (who authored the change, which proposal it came from, who approved it). Agents draft; humans decide.
Protected scenes
Every scene has a require_approval flag (default false). When it is on:
- API-key callers (agents) get a structured
409 PROPOSAL_REQUIREDfrom bothPOST /v1/scenes/:id/applyOpsandPOST /v1/scenes/:id/derive. The recovery is always the same: submit the same ops as a proposal. - Session callers (humans in the dashboard) keep direct write access. The flag protects against unattended mutation, not against the owner.
Toggle it with PATCH /v1/scenes/:id/protection and body { "requireApproval": true | false }. The toggle is a ratchet: any authenticated owner - including an API-key agent - may turn protection ON (self-restriction is safe, and lets a CI setup script protect the scenes it creates), but turning it OFF requires a signed-in human session. An agent can never unprotect a scene to bypass review.
Lifecycle
A proposal moves through exactly one of these paths:
open ── approve ──▶ approved (ops applied; resultingRevision recorded)
open ── reject ───▶ rejected (nothing applied; resolutionReason recorded)
open ── scene moved on ──▶ superseded (nothing applied; re-propose)
Terminal states are immutable. Approving or rejecting an already-resolved proposal returns 409 PROPOSAL_ALREADY_RESOLVED.
Endpoints
| Endpoint | Auth | What it does |
|---|---|---|
POST /v1/scenes/:id/proposals | key or session | Create. Ops are validated against the scene’s current revision (422 on failure) but NOT applied. A stale baseRevision returns 409 PROPOSAL_BASE_STALE. |
GET /v1/scenes/:id/proposals | key or session | List for one scene, newest first. ?status=open|approved|rejected|superseded. |
GET /v1/scenes/:id/proposals/:pid | key or session | Fetch one proposal with ops, status, and resolution. |
GET /v1/proposals | key or session | Cross-scene inbox for everything the caller owns. Summary items (ops replaced by opsCount, scene titles attached). |
POST /v1/scenes/:id/proposals/:pid/approve | session only | Apply the ops as a new revision with provenance, mark the proposal approved. API keys get 403 SESSION_AUTH_REQUIRED. |
POST /v1/scenes/:id/proposals/:pid/reject | session only | Close the proposal without applying. Optional { "reason": "..." } is stored as resolutionReason for the agent to read. |
POST /v1/scenes/:id/proposals/:pid/preview | key or session | Apply the ops in memory against the current revision, render SVG, and return it with the structural diff. Nothing is persisted. |
PATCH /v1/scenes/:id/protection | see above | Toggle require_approval (ratchet). |
MCP tools mirror the agent-side surface: dsp_propose_ops, dsp_list_proposals, dsp_get_proposal (since @zindex-ai/mcp 0.43.0). There is deliberately no MCP approve tool - approval is impossible over API-key auth, which is what makes “agents never approve their own proposals” a server-side guarantee rather than a convention.
Conflict semantics: never a silent rebase
The scene can move past a proposal’s baseRevision before a human reviews it. On approval the server re-validates the proposed ops against the CURRENT revision:
- Ops still valid (they touch elements the interim changes left alone): the approval applies them against the current revision. The revision history shows exactly what happened.
- Op-target conflicts (an op references an element that no longer exists, a duplicate id, etc.): the proposal is marked
supersededand the approval returns409 PROPOSAL_STALEwith the validation diagnostics. The agent reads the current scene and re-proposes. The server never rewrites proposed ops to fit a moved scene.
Creating a proposal against a stale baseRevision fails immediately with 409 PROPOSAL_BASE_STALE - re-read the scene first.
One rare fourth code: 409 PROPOSAL_APPLY_FAILED means the ops re-validated cleanly but the executor hit a conflict applying them (usually a write raced the approval). The proposal stays open; retry the approval.
Provenance model
Every revision records who produced it. The four fields appear in GET /v1/scenes/:id/revisions (see Revisions):
createdByKind:agent(API-key caller),human(session caller), orderive(the deterministic schema-sync writer).createdVia:mcp,api, orcli.proposalId+approvedByUserId: set when the revision landed through an approved proposal - the author and the approver are recorded separately, which is the audit property the review loop exists to provide.
Direct writes record author kind and transport with null proposal fields. Revisions written before provenance recording landed have all four fields null.
Derive-proposals: syncing a protected scene with attested lineage
POST /v1/scenes/:id/proposals accepts a second body shape: { "format": "prisma" | "sql", "source": "<full schema snapshot>" } instead of ops. The server runs the deterministic derivation inside the proposal, so the proposed ops and the snapshot’s SHA-256 (deriveFormat, deriveSourceSha256) are recorded by the API rather than claimed by the agent. On approval the lineage carries onto the landed revision, and from there into evidence packs - the pack’s answer to an auditor’s “does this diagram match the source environment?” is the fingerprint of the schema it was derived from.
This is the recovery path when dsp_derive_scene returns 409 PROPOSAL_REQUIRED on a protected scene: re-submit the same format + source as a derive-proposal. Do not hand-copy derive-preview ops into a plain ops-proposal - the change lands the same, but the lineage is lost. A derive-proposal that finds nothing to change returns 200 { "proposed": false } and creates no proposal. Never send ops and format/source together.
Plan limits
Creating a proposal is free. Approval counts toward the workspace’s updatesPerMonth limit - the moment a revision lands is the moment usage is charged.