# Form schema

Each form is a TypeScript file in `mirio/forms/<slug>.ts` that exports the result of `defineForm`. `defineForm` validates the schema at module load, so a bad schema fails fast in the site's own build and in `mirio push`.

```ts
import { defineForm } from '@trymirio/mirio/schema';

export const contact = defineForm({
  slug: 'contact',            // lowercase letters, numbers, dashes; stable; used in the URL
  name: 'Contact',
  description: 'Shown in the dashboard only',
  pages: [ /* one or more pages */ ],
  settings: { /* optional */ },
});
```

## Pages

A form is a list of pages. A classic form has one page; a multi-step form has many. Pages are the unit for partial saves and step completion.

```ts
pages: [
  { key: 'about', title: 'About you', description: 'Takes a minute', fields: [ /* … */ ] },
  { key: 'project', title: 'Your project', fields: [ /* … */ ] },
]
```

`key`, `title` and `description` are optional. Every page needs at least one field.

## Fields

Every field has a stable `key` (unique across all pages), a `type`, and a `label`. Optional on every field: `help`, `placeholder`, `required`, `renamedFrom`, `defaultValue`.

| Type | Value stored | Constraints |
| --- | --- | --- |
| `short_text` | string, trimmed | `minLength`, `maxLength` (default 500), `pattern` (regex source) |
| `long_text` | string, trimmed | `minLength`, `maxLength` (default 10000), `pattern` |
| `email` | string, lowercased | |
| `phone` | E.164 string, e.g. `+15555550123` | `defaultCountry` (ISO-2, default `US`) for numbers typed without a country code |
| `url` | normalized URL; `https://` added when no scheme | |
| `number` | number | `min`, `max`, `integer` |
| `single_choice` | one option value | `options` (required) |
| `multiple_choice` | array of option values | `options` (required), `minSelected`, `maxSelected` |
| `dropdown` | one option value | `options` (required) |
| `yes_no` | boolean | `options` (optional, exactly two: the labels shown for yes and no) |
| `rating` | integer `min`..`max` | `min` (default 1, may be 0), `max` (default 5, 2 to 10) |
| `date` | `YYYY-MM-DD` | `min`, `max` as ISO dates |
| `consent` | boolean; `required` means it must be true | |
| `hidden` | string; not rendered | `defaultValue` |

Also on every field: `autocomplete` (the HTML autocomplete token: `name`, `given-name`, `email`, `tel`, `organization`, `url`), `prefill: { query: 'utm_term' }` to take the initial value from a URL parameter (hidden fields default to their own key; when the form's page lacks it, the visit's landing page is used, see [hidden fields](/docs/attribution.md#hidden-fields)), and `showWhen` for [conditional logic](/docs/conditional-logic.md).

There is no separate name field. Use `short_text` with `autocomplete: 'name'` (or `given-name` and `family-name` for two fields); browsers then autofill it, and `schedule` success links pick it up.

`options` accept strings or `{ value, label }` objects:

```ts
{ key: 'plan', type: 'dropdown', label: 'Plan', options: ['Free', { value: 'pro', label: 'Pro ($29/mo)' }] }
```

### Renaming a field

Keys are what the site code and the API use, but submissions are stored by a stable field id. Declare the old key so the data stays together:

```ts
{ key: 'full_name', type: 'short_text', label: 'Full name', renamedFrom: ['name'] }
```

The submissions table shows one column, and the version diff reports a rename rather than a removal plus an addition.

### Fields Mirio refuses

Keys or labels that look like passwords, card numbers, CVV codes or social security numbers are rejected by `defineForm` and by `push`. Mirio is not a place for secrets. Use a payment provider for cards.

## Settings

The schema file holds what the visitor sees and what the form accepts:

```ts
settings: {
  success: { type: 'message', title: 'Thanks', message: 'We will be in touch.' },
  // or: success: { type: 'redirect', url: 'https://example.com/thanks', prefill: { ref: 'email' } },
  // or: success: { type: 'schedule', url: 'https://calendly.com/versive/demo' },   // Calendly or Cal.com, name and email prefilled
  submitLabel: 'Request a demo',
  agents: 'accept',                     // 'accept' (default; declared agents are labeled) | 'block'
  agentNotes: 'What this form is for and what good answers look like',
  turnstileSiteKey: '0x4AAAAAAA…',      // optional: your Cloudflare Turnstile widget; Mirio also needs its secret key (see Spam)
}
```

`schedule` sends the visitor to a Calendly or Cal.com page with `name`, `email` and any `utm_*` parameters filled in, so the demo is booked in the same flow. The visitor's browser runs this code, so `success` from the file is what they see. Settings are applied at registration and are not part of the schema version. Changing a label, option or constraint is; every distinct schema becomes a new version with a visible diff. `agents` also accepts the old `'allow'` and `'flag'`, both meaning `'accept'`.

### What lives in Mirio instead

Everything the team changes without a deploy lives in Mirio (the dashboard, the CLI or the API), not in the file:

| Setting | Where |
| --- | --- |
| Allowed domains (per site) | `npx mirio domains add example.com`, dashboard: Sites → the site → Domains |
| Notifications and integrations (per form) | `npx mirio destinations add email\|slack\|webhook\|postgres … --form <slug>` or `--all-forms`; email goes to workspace members only. See [Destinations](/docs/destinations.md). |
| Triage: ideal response, summary, categories, on/off | `npx mirio review <slug> --ideal "…"`. See [AI review](/docs/ai-review.md). |
| Turnstile secret key (per site) | `npx mirio turnstile set <secret key>`. See [Turnstile](/docs/spam.md#turnstile). |

`settings.notifyEmails`, `settings.allowedOrigins` and `settings.review` still validate for one release. The first push imports `notifyEmails` (member addresses only) and `review` when Mirio has nothing set for that form; per-form `allowedOrigins` are dropped (domains belong to the site). `mirio status` and the dev server print one line per schema that still has them; remove them from the file.

## Versions

`mirio push` computes a hash of the canonical schema (pages, fields and their properties, not settings). A new hash becomes a new version. Submissions record the version they were made against. If a form is submitted with a hash that was never pushed, the submission is accepted, validated against the latest pushed version, flagged `unregistered_schema`, and the owner gets one email. Run `mirio push` to clear the flag for future submissions.
