
# Form Submissions

Every form submission produces a durable record. By default it lands in the built-in `form_submissions` ledger — the platform's canonical "this form received an answer" log — and, when `submitTo.table` is set, in your own table too. The two writes are transactional, so you never end up with a ledger entry promising a record that was never written. After the writes commit, `onSuccess` decides what the submitter sees and `onError` covers failures.

```yaml
forms:
  - id: 1
    name: contact
    submitTo:
      table: leads
      # storeSubmission defaults to true — table AND ledger
    fields:
      - { kind: table-field, column: email, required: true }
    onSuccess:
      type: toast
      variant: success
      message: Thanks! We'll be in touch.
```

## The Submission Ledger

The `form_submissions` ledger lives in an internal Sovrium-managed schema (mirroring the isolation of `auth.*`) — you never create or manage it yourself. Every submission writes one ledger row by default; set `submitTo.storeSubmission: false` to opt out (in which case `table` or `automation` becomes mandatory).

| Column                 | Type           | Description                                                                       |
| ---------------------- | -------------- | --------------------------------------------------------------------------------- |
| `id`                   | UUID           | Stable submission id; surfaced to `onSuccess` templates via `$submission.id`.     |
| `form_name`            | text           | The `forms[].name` at submission time.                                            |
| `form_id`              | integer        | The `forms[].id` at submission time (denormalized for joins).                     |
| `submitted_at`         | timestamptz    | Server timestamp when the row was created.                                        |
| `submitter_user_id`    | UUID, nullable | The authenticated user, when the form requires authentication.                    |
| `submitter_ip`         | inet, nullable | Submitter IP (stored hashed by default) — used by anti-spam and audit.            |
| `submitter_user_agent` | text, nullable | Submitter user-agent string.                                                      |
| `data`                 | jsonb          | The full validated answer payload (mirrors what was written to `submitTo.table`). |
| `linked_record_table`  | text, nullable | The bound table name, when `submitTo.table` is set.                               |
| `linked_record_id`     | text, nullable | The inserted record's id (text-typed to handle integer / UUID / string ids).      |
| `status`               | enum           | Lifecycle state (see below).                                                      |
| `status_reason`        | text, nullable | Short reason when `status` is `failed` or `spam`.                                 |

## Lifecycle Status

| State        | Meaning                                                                            | Transitions to                                    |
| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------- |
| `received`   | Accepted; written to the ledger and bound table (if any).                          | `processing` (if automation), `done` (otherwise). |
| `processing` | The bound automation is running.                                                   | `done` on success, `failed` on terminal failure.  |
| `done`       | Terminal — all writes and the bound automation completed.                          | —                                                 |
| `spam`       | Terminal — the anti-spam pipeline classified it as spam; preserved for moderation. | —                                                 |
| `failed`     | Terminal — a downstream step failed and was not retried; `status_reason` is set.   | —                                                 |

## The Dual-Write Contract

When `submitTo.table` is set, the submission writes one row to that table **and** one ledger row inside a single transaction. If the table write fails — a constraint violation, type error, or foreign-key miss — the ledger row rolls back too, and the submitter receives an HTTP 4xx/5xx error with `fieldErrors:[{ name, message }]` naming the offending column. No "ghost" ledger entry is ever left behind.

When `submitTo.automation` is set, the automation is invoked **after** both writes commit. An automation failure does **not** roll back the writes — the submission is recorded, `status` moves to `failed`, and the automation's own retry/failure machinery applies.

```yaml
forms:
  - id: 2
    name: stream-event
    submitTo:
      automation: post-event-to-stream
      storeSubmission: false # opt out — ledger is skipped; automation handles persistence
    fields:
      - { kind: standalone, name: event_payload, inputType: long-text }
```

:::callout
**Bypass the ledger only when you mean it.** `storeSubmission: false` is the only way to skip the ledger and is intended for very high-volume forms that route to a stream or queue. Whenever it is set, `submitTo` must still specify a `table` or an `automation`, or validation fails — a form that persists nowhere is a configuration bug.
:::

## On Success

The `onSuccess` object decides what the submitter sees after a successful submission. It is a discriminated union on `type`.

| `type`        | Behavior                                                                                                                                       |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `successPage` | Render a custom success page (`title`, `message`, optional `buttonLabel` + `buttonHref`, optional `actions[]`, `showSummary`).                 |
| `redirect`    | Navigate to `url` after an optional `delaySeconds` (default 2; `0` is immediate). Supports `$submission.id` / `$record.id` template variables. |
| `reset`       | Clear the form for another submission, with an optional `message` and `preserveFields[]` to retain.                                            |
| `toast`       | Show a transient toast (`message`, optional `variant`: `success` / `info`).                                                                    |
| `message`     | Replace the form with an inline `message` in place.                                                                                            |

```yaml
onSuccess:
  type: redirect
  url: /thank-you?ref=$submission.id
  delaySeconds: 0
```

A `successPage` may render `actions[]` buttons — each is either `action: reset` (submit another) or `action: navigate` (link to `url`) — and `showSummary: true` lists the submitted values, honoring hidden fields and per-field read permissions.

## On Error

The `onError` object controls the message shown when a submission fails server-side.

| Property  | Description                                                               |
| --------- | ------------------------------------------------------------------------- |
| `type`    | `toast`, `message` (inline), or `errorPage` (full page).                  |
| `message` | Body message. Supports `$t:` keys.                                        |
| `title`   | Optional title (used by `errorPage`).                                     |
| `variant` | Toast variant (`error` / `warning`) — only meaningful when `type: toast`. |

```yaml
onError:
  type: message
  message: Something went wrong saving your request. Please try again.
```

:::callout
**Field-level validation errors are separate.** Server-side validation failures (e.g. an invalid email or a foreign-key violation) return HTTP 400 with a `fieldErrors:[{ name, message }]` envelope and are surfaced inline on the offending field — independently of `onError`, which covers whole-submission failures.
:::

## Related Pages

- [Forms Overview](/en/docs/forms-overview) — `submitTo`, `storeSubmission`, and the `onSuccess` / `onError` schema.
- [Form Fields](/en/docs/form-fields) — per-field `permissions` that redact ledger exports.
- [File Uploads](/en/docs/form-file-uploads) — file metadata stored in the ledger `data` payload.
- [Tables Overview](/en/docs/tables-overview) — the bound table the dual-write targets.
