WorldForge Studio

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.

QueryTypeNotes
statusopen | committed | rejected | expireddefault open
changeset_idstringoptional, must match the changeset id pattern
limitint 1–200default 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

MethodPathNotes
GET/proposalsList/filter proposals
POST/proposals/:id/commitApply — { fields? } for selective accept; owner-only overrides
POST/proposals/:id/rejectReject
POST/proposals/:id/undoUndo 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.

MethodPathNotes
POST/proposal-changesets/:id/commitCommit the whole set
POST/proposal-changesets/:id/rejectReject the whole set

Auto-accept policies — POST /worlds/:id/proposal-policies family

MethodPathNotes
GET/worlds/:id/proposal-policiesList rules
POST/worlds/:id/proposal-policiesCreate (user session only — no API keys)
PATCH/worlds/:id/proposal-policies/:policyIdUpdate
DELETE/worlds/:id/proposal-policies/:policyIdDelete
POST/worlds/:id/auto-accept/pauseWorld-level kill switch
POST/worlds/:id/auto-accept/resumeLift 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.

RouteWhoNotes
GET …/proposal-policiesowner or GM200 { viewerRole, pausedAt, policies[], catalogue } — catalogue lists which entity types/fields may be auto-accepted
POST …/proposal-policiesowner (GM: 403 only_the_owner_can_create_a_policy)201 { policy }
PATCH …/proposal-policies/:policyIdowner; a GM may send only { mode: 'off' }200 { policy }; otherwise 403 only_the_owner_can_change_a_policy
DELETE …/proposal-policies/:policyIdowner (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 | /resumeowner or GM, user session200 { pausedAt } (ISO string, or null after resume)

POST body (strict — unknown keys are 400):

FieldTypeNotes
modeoff | shadow | ondefault shadow
entity_typeenum of auto-acceptable typesrequired (values in catalogue)
operationcreate | editrequired
allowed_fieldsstring[] 1–20required; deduplicated; must all be auto-acceptable for the entity type/operation
max_patch_bytesint 1–262144required
daily_limitint 1–1000required
proposer_agent_idstring | nulloptional; 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.