In short: A Postbag form schema is an immutable, versioned JSON Schema (draft 2020-12) plus UI hints. Forms run in observe mode (accept everything, detect drift), enforce mode (validate; violations are quarantined, never dropped) or managed mode (Postbag owns the schema and serves it at /s/{id}/schema so the site renders the form from it).
Three modes, one default
observe (default): accept everything. If a schema exists, compare each submission to it and raise drift events on differences. If none exists, infer one in the background and offer it as a draft.
enforce: validate against the current version. Violations are stored as quarantined with reason schema_violation and raise a drift event. Nothing is rejected, nothing is dropped.
managed: Postbag owns the schema. GET /s/{id}/schema serves it with open CORS and sites render the form from it. The site cannot drift because it has no schema of its own. This is the mode an agent uses when it builds the sixteenth site in a fleet from a stream's template.
Versions are immutable
Publishing a schema creates a new row: form_schemas (form_id, version) is unique and rows are never updated. Submissions and deliveries record the version they were validated against, so an old delivery still means what it meant. Stream schemas work the same way and are the outbound contract for every route on the stream.
curl -X POST https://postbag.dev/v1/forms/fm_8f3kq2/schema -H "Authorization: Bearer pb_live_…" -d '{
"json_schema": { "type": "object", "required": ["email"],
"properties": { "email": { "type": "string", "format": "email" }, "message": { "type": "string" } } },
"ui": { "email": { "label": "Email", "widget": "email", "order": 1 }, "message": { "label": "Message", "widget": "textarea", "order": 2 } },
"changelog": "v2: message optional"
}' curl -X POST https://postbag.dev/v1/forms/fm_8f3kq2/schema -H "Authorization: Bearer pb_live_…" -d '{
"json_schema": { "type": "object", "required": ["email"],
"properties": { "email": { "type": "string", "format": "email" }, "message": { "type": "string" } } },
"ui": { "email": { "label": "Email", "widget": "email", "order": 1 }, "message": { "label": "Message", "widget": "textarea", "order": 2 } },
"changelog": "v2: message optional"
}' Drift
A drift event records form, submission, kind (new_field, missing_field, type_change) and details, and stays open until someone publishes a new version or dismisses it. GET /v1/forms/{id}/drift lists them; the dashboard shows a "Change detected" badge. Organization system webhooks can subscribe to drift.detected, so a CRM or a site factory learns about a change without polling.
Inference
For observe forms with no schema, POST /v1/forms/{id}/schema/infer (and the background housekeeping loop) builds a draft from recent submissions: field names, types and which fields were always present. You review and publish it as v1. Inferred versions are flagged as such.