07 — Proposals & auto-accept
The agent-proposal review queue: inbox, commit/reject, changesets, and auto-accept policies.
Every AI write lands as a proposal, never a direct document mutation (spec 2026-09-15). Reviewers are the world's owner and GMs.
Inbox — GET /worlds/:id/proposals
Returns a world's proposals, newest first, with stale/gone/current values
for each field so the reviewer sees what would actually change. SSE pushes
proposal events to owner/GMs as they arrive (ids and status only, never
the patch).
Only the world's owner and GMs may read it; anyone else (and a missing
world) gets 404 { error: 'not_found' }; no user behind the caller is
403 { error: 'forbidden', reason: 'unresolved_caller' }. Open proposals
past PROPOSAL_TTL_DAYS are expired before listing, so the inbox never shows
one that can no longer commit.
| Query | Type | Notes |
|---|---|---|
status | open | committed | rejected | expired | default open |
changeset_id | string | optional, must match the changeset id pattern |
limit | int 1–200 | default 100 |
Response:
{
"viewerRole": "owner", // or "gm" — only the owner may edit before accepting
"autoAcceptPausedAt": null, // ISO string while the kill switch is on
"proposals": [ /* wire proposal + stale, entityGone, current values, expiresAt */ ],
"changesets": [ { "id", "count", "openCount", "proposerId", "proposerAgentId", "createdAt" } ],
"counts": { "open": 3, "stale": 1 } // stale is only computed for status=open, else 0
}
changesets[].count / openCount are totals across all statuses for each
changeset that appears on the page. The exact per-proposal extra fields come
from proposals/inbox.ts and are not enumerated here.
Proposals — /proposals
| Method | Path | Notes |
|---|---|---|
GET | /proposals | List/filter proposals |
POST | /proposals/:id/commit | Apply — { fields? } for selective accept; owner-only overrides |
POST | /proposals/:id/reject | Reject |
POST | /proposals/:id/undo | Undo a committed proposal (uses stored prior values) |
Commits re-check authorization inside the transaction. Scope:
world:commit (rule-derived; GET needs world:read).
Changesets — /proposal-changesets
One chat turn = one changeset; commit/reject is atomic — every failure is reported and nothing is partially applied.
| Method | Path | Notes |
|---|---|---|
POST | /proposal-changesets/:id/commit | Commit the whole set |
POST | /proposal-changesets/:id/reject | Reject the whole set |
Auto-accept policies — POST /worlds/:id/proposal-policies family
| Method | Path | Notes |
|---|---|---|
GET | /worlds/:id/proposal-policies | List rules |
POST | /worlds/:id/proposal-policies | Create (user session only — no API keys) |
PATCH | /worlds/:id/proposal-policies/:policyId | Update |
DELETE | /worlds/:id/proposal-policies/:policyId | Delete |
POST | /worlds/:id/auto-accept/pause | World-level kill switch |
POST | /worlds/:id/auto-accept/resume | Lift it |
Modes: off, shadow (log only, never apply) and on. Hard exclusions
always win. Auto-committed audit rows carry actor_kind = 'policy'. Policy
writes require a user session (deliberately not API-key-able: an agent
gets 403 { error: 'forbidden', reason: 'user_session_required' }).
Access: everything here is owner-or-GM; anyone else (and a missing world)
gets 404 { error: 'not_found' }; no user behind the caller is
403 unresolved_caller.
| Route | Who | Notes |
|---|---|---|
GET …/proposal-policies | owner or GM | 200 { viewerRole, pausedAt, policies[], catalogue } — catalogue lists which entity types/fields may be auto-accepted |
POST …/proposal-policies | owner (GM: 403 only_the_owner_can_create_a_policy) | 201 { policy } |
PATCH …/proposal-policies/:policyId | owner; a GM may send only { mode: 'off' } | 200 { policy }; otherwise 403 only_the_owner_can_change_a_policy |
DELETE …/proposal-policies/:policyId | owner (GM: 403 only_the_owner_can_delete_a_policy) | 204. Soft delete: the row is set off + disabled_at so past decided_by = 'policy:<id>' still resolves |
POST /worlds/:id/auto-accept/pause | /resume | owner or GM, user session | 200 { pausedAt } (ISO string, or null after resume) |
POST body (strict — unknown keys are 400):
| Field | Type | Notes |
|---|---|---|
mode | off | shadow | on | default shadow |
entity_type | enum of auto-acceptable types | required (values in catalogue) |
operation | create | edit | required |
allowed_fields | string[] 1–20 | required; deduplicated; must all be auto-acceptable for the entity type/operation |
max_patch_bytes | int 1–262144 | required |
daily_limit | int 1–1000 | required |
proposer_agent_id | string | null | optional; an API key id of the owner (null/omitted = the owner's /ai chat) |
PATCH takes the same fields, all optional (and still strict). Validation
failures are 400 { error: 'invalid_policy', reason } with
fields_not_auto_acceptable (+ fields, allowed) or unknown_api_key;
404 for a policy that does not exist, belongs to another world or was
deleted. Each write is audited (world:proposal_policy_create / _update /
_delete, world:auto_accept_pause / _resume).
Paths: policies live under /worlds/:id/proposal-policies; only the
pause/resume kill switch lives under /worlds/:id/auto-accept. There is no
GET /worlds/:id/auto-accept and no /proposal-policies/pause.
Proposal creation (where they come from)
Entity PATCH routes with propose: true and the AI agent's
propose_* MCP tools both land here — see
11 — MCP and 02 — Worlds.