---
title: "Validation"
description: "Compile Vine validators that mirror reactive rules and protect persistence."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Validation

Adonia compiles each field's Vine schema, mirrors reactive rules server-side,
caches safe validators, and enforces the persistence allowlist. Vine 4 has no
built-in database rules, so Adonia supplies query-backed `unique` and `exists`
rules and batches relation checks before validation. This page documents the
current implementation.

Everything lives in `packages/core/src/validation/`: `validator_compiler.ts`
(compilation and the mirroring pipeline), `field_meta.ts` (the R1–R5
decisions), `fill.ts` (§19), `sets.ts` (R6), `json_logic.ts` (the rule
evaluator). The per-field hand-off is
`packages/core/src/schema/validation_spec.ts`.

The client half is [the form engine](/form-engine); the rule vocabulary and
its fixtures are [reactivity, server side](/reactivity/server).

<!-- @sample-preamble
import { BaseResource, F, applyFill, compileValidator, dehydrateFill, registerTransform, when } from '@adonia/core'
import type { HttpContextLike, SchemaBuilder, SchemaComponent } from '@adonia/core'
import Post from '#models/post'
declare const resource: BaseResource
declare const ctx: HttpContextLike
declare const record: object
declare const body: Record<string, unknown>
-->

## Compiling a validator

```ts
const validator = compileValidator(resource, 'edit', ctx, record)
```

`compileValidator(resource, mode, ctx?, record?)` takes a **resource instance**
(resources are per-request, §6.1), a mode, and optionally the request context
and the record under edit. It is a pure function — it never reads a cache — so
offline tooling, the conformance fixtures and the testing helpers get a
validator compiled from the schema in front of them. The HTTP path calls
`cachedValidator` in the same module instead, memoized per §11.3 on
`(resource generation, slug, mode, resolved ability map)`; the tenant is
deliberately *not* in the key, because by contract tenant and record ids
travel in validator meta rather than in the schema.

`ctx` is what makes the compile authorization-aware: `collectFieldMetas` applies
the compile-time `canSee` / `requiresAbility()` projection (mirroring rule R2),
so a field the submitting user may not see is absent from the validator *and*
from the fill set. `record` is forwarded to record-dependent predicates in
`edit`.

`ValidatorMode` is `'create' | 'edit'` — there is no third value. The mode
reaches every field as `ValidationBuildOptions.mode` and narrows the fill set
through `readonlyOn` (below).

`CompiledValidator` is immutable and safe to reuse across requests:

| Member | What it answers |
| --- | --- |
| `mode` | the mode it was compiled for |
| `fields` | the `FieldMeta[]` the compilation was derived from |
| `stateFor(data)` | the R1 evaluation state: declared defaults merged **under** the submission |
| `prune(data)` | a copy of `data` with every hidden key deleted (R3) |
| `validate(data)` | the full pipeline: prune → Vine → `sets` overlay. Throws `E_VALIDATION_ERROR` |
| `keysFor(state)` | the exact validator key set (R2+R3) |
| `requiredFor(state)` | the exact required key set (R4) |
| `fillFor(state)` | the exact fill key set (§7.4/§19) |
| `derivedFor(state)` | the `target → value` map of `sets` derivations (R6) |

The whole submit, as the resource controller runs it:

```ts
async function save(): Promise<void> {
  const validator = compileValidator(resource, 'edit', ctx, record)
  const output = await validator.validate(body)

  const state = validator.stateFor(body)
  const fillKeys = validator.fillFor(state)
  const attributes = await dehydrateFill(resource, output, fillKeys, ctx, state, 'edit')

  applyFill(record, attributes, fillKeys)
}
```

`validate` throws; `stateFor`/`fillFor` do not. `dehydrateFill` runs outside the
transaction because a user `dehydrate()` mapper may await I/O, and `applyFill`
runs inside it, right after the §12 re-authorization.

## The compiler never branches on field classes

There is exactly one rule holding §11 together, and it is a rule about *code*,
not about validation: **the compiler does not know what a `text-input` is.**

`Field.buildValidation(options)` is the whole hand-off:

1. the base is `rules(vine)` when the author declared one, otherwise the field
   type's own `vineSchema(options)`;
2. `applyValidationModifiers` then applies the cross-cutting modifiers in the
   order Vine requires — `nullable()` → `optional()`, and nothing else.

`vineSchema` is `protected abstract`, so a field type cannot exist without a
§11.1 mapping, and `applyValidationModifiers` is shared, so optionality and
nullability behave identically for every type. The compiler only ever decides
*whether* a key is optional and, when the answer is "it depends", wraps the key
in the §11.2 group pair; the base comes from below.

What that buys: a custom field type gets validation, mirroring, fill and error
paths for free. Implement `vineSchema` and the field participates in prune,
bucketed groups, `requiredWhen`, the fill set and dotted error paths without
touching the compiler. It is also why a repeater works at all — `Repeater`'s
own `vineSchema` calls `buildFieldObjectSchema`, the *same* function that
compiles the resource, so an item sub-form validates exactly as a form does,
recursively, with no nesting-aware branch anywhere.

`packages/core/tests/fields/mapping_table.spec.ts` enforces the other half: it
walks the `F` namespace itself and fails any registered factory that has no
wire type, no display projection, no explicit column decision, or a §11.1 base
that does not compile.

## §11.1 — what each field type contributes

Read off the `vineSchema` overrides in `packages/core/src/schema/fields/`. The
modifier column lists what the base reads from the field's own props; the
shared `nullable`/`optional`/`requiredWhen` layer is applied on top of all of
them.

| Factory (wire `type`) | Vine base | Reads |
| --- | --- | --- |
| `F.text` (`text-input`), `F.textarea` (`textarea`) | `vine.string().trim()` | `minLength`, `maxLength`, `mask` → `regex` |
| `F.number` (`number`) | `vine.number()` | `min`, `max`, `decimal` → `decimal([0, places])` |
| `F.slider` (`slider`) | `vine.number()`, or `vine.array(bounded).fixedLength(2)` when `range()` | `min`, `max`, `decimal` |
| `F.select` (`select`) | `vine.enum(values)`, or `vine.array(vine.enum(values))` when `multiple()` | declared `options` |
| `F.radio` (`radio`) | `vine.enum(values)` | declared `options` |
| `F.checkboxList` (`checkbox-list`) | `vine.array(vine.enum(values))` | `minSelected`/`maxSelected` → array `minLength`/`maxLength` |
| `F.checkbox` (`checkbox`), `F.toggle` (`toggle`) | `vine.boolean()` | — |
| `F.date`, `F.datetime`, `F.time` | `vine.date({ formats })` at the subtype's granularity | `after`, `before`, each carrying an explicit compare `format` |
| `F.color` (`color`) | `vine.string().regex(…)` | `notation`: hex, oklch, or either |
| `F.hidden` (`hidden`) | `vine.string()`, or `vine.number()` / `vine.boolean()` | `valueType()` |
| `F.repeater` (`repeater`) | `vine.array(<item object>)` | `min`, `max` |
| `F.keyValue` (`key-value`) | `vine.record(vine.string())` | `minPairs`/`maxPairs` |
| `F.json` (`json`) | `vine.any()` | — narrow it with `rules()` |
| `F.code` (`code`) | `vine.string()` — no `trim()` | `minLength`, `maxLength` |
| `F.markdown` (`markdown`) | `vine.string()` — no `trim()` | `minLength`, `maxLength` |
| `F.richText` (`rich-text`) | `vine.string()` — no `trim()` | `minLength`, `maxLength` |

Three of those omissions are deliberate and worth naming. `code` and `markdown`
do not trim because trailing whitespace is significant in both (two trailing
spaces are a markdown line break). `richText` does not trim because the value is
markup and trimming would make the sanitizer's output differ from its input for
reasons unrelated to safety. And `decimal(places)` compiles to `decimal([0,
places])`, not the scalar form: `vine.number().decimal(2)` demands *exactly* two
decimals, which would reject `10` for a price.

A choice field that declares **no** static options but does declare
`dependentOptions()` falls back to a shape-only base — its option set only
exists once the server resolves it for the submitted state, so membership is
checked by a batched pre-validation step (the batched relation-check contract) rather than an enum.

Field-by-field props are documented in [the `F` namespace reference](/fields).

## The field-level DSL

### `required()`

`required()` sets `props.required = true`, which the compiler reads as "not
optional". `required({ field, matcher })` is sugar for
`requiredWhen(field, matcher)` — the same declarative rule, mirrored
server-side, not a static flag.

A required field is not unconditionally required: `isRequiredFor` returns
`false` when the field is hidden or disabled for the submitted state (R4/R5).
That is a correctness requirement, not a leniency — demanding a value for an
input the UI could never present would reject submissions nobody can fix.

### `nullable()`

Allows `null` as a submitted value. It is recorded twice on purpose: on the
field (`isNullable`, consumed by `buildValidation`) and in `props.nullable`
(which rides the wire). Distinct from optional: `nullable` is about the *value*,
`optional` about the *key*.

### `unique()`

```ts
F.text('slug').unique()
F.text('email').unique({ table: 'users', column: 'email_address', ignoreSelf: false })
```

`unique(opts)` records `{ ignoreSelf: true, ...opts }` on the field, readable as
`field.uniqueSpec`. `ignoreSelf` defaults to **`true`**, which is what makes an
edit that leaves the value untouched pass: the probe adds
`whereNot(primaryKey, currentId)` in `edit` mode, taking the id from validator
meta rather than from the input (the edit uniqueness contract). `table` and `column` default
to the resource model's table and the field attribute's column, and an active
tenant scope (§5.3) folds into the same query.

Per the database-rule contract the probe is a **first-party** `vine.createRule` rule
named `database.unique`, closing over the Lucid query builder — Vine 4.4 has no
`.unique()` of its own; the spec sentence claiming otherwise is the database-rule contract.
The measured SQL is `SELECT id FROM posts WHERE slug = ? AND tenant_id = ?
LIMIT 1`, plus `AND id != ?` in edit.

**In this build `unique()` is declaration-only.** The spec is recorded on the
field and nothing reads it yet: no `vineSchema` consumes `uniqueSpec`, so no
`database.unique` rule is emitted and no query runs. Declare it — the shape is
frozen and the DSL will not change — but rely on a database unique index for
the guarantee until the rule lands.

### `minLength()` / `maxLength()`

Both write into the props bag (`props.minLength` / `props.maxLength`) and are
read by the *field type's* `vineSchema`. Two consequences follow. They only bind
on types whose base reads them — the string family, plus `code`, `markdown` and
`richText`; on `F.number` the bounds are `min`/`max` instead. And because they
live in props, they also ride the wire and become rendering hints on the
control.

### `rules()`

```ts
F.text('slug')
  .required()
  .rules((v) => v.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).maxLength(160))
```

`rules(fn)` hands you the `vine` root and **replaces the field type's entire
base**. Everything `vineSchema` would have produced is gone: the `trim()`, the
enum membership, the date parse formats and `after`/`before` bounds, the numeric
`min`/`max`/`decimal`, and — the easy one to miss — the `minLength`/`maxLength`
you declared through the DSL, because those are props that only `vineSchema`
reads. They still ride the wire as rendering hints, so the control will look
constrained while the server no longer enforces it. Restate every constraint you
still want inside the callback.

What survives is everything applied *around* the base:

- `nullable()` → `.nullable()`,
- the compiler's optionality decision (from `required()` and R4),
- and the entire §11.2 pipeline: prune, bucketed groups, the complementary group
  pair that conditional requiredness compiles to, the fill set, `sets`
  re-derivation, dotted error paths.

So a `rules()` override changes the *shape* check and nothing else.

```ts
F.json('meta')
  .nullable()
  .rules((v) =>
v.object({
  canonicalUrl: v.string().maxLength(2048),
  noindex: v.boolean(),
})
  )
```

This is the intended use of `F.json`, whose own base is `vine.any()`.

One friction point is worth knowing before you reach for `.nullable()` or
`.optional()` **inside** a `rules()` callback: under `exactOptionalPropertyTypes`
— which this repo and `tsconfig.base.json` both enable — Vine 4's
`NullableModifier<T>` and `OptionalModifier<T>` do not satisfy `SchemaTypes`
(their `isOptional`/`allowNull` widen to `boolean | undefined`, and
`ConstructableSchema` declares both as optional properties), so a modified
member inside a nested `v.object({ … })` fails to typecheck — which is why the
members above are declared bare. That is why `applyValidationModifiers` narrows
through a small structural interface instead of chaining directly; it returns
`SchemaTypes`, so it is also the escape hatch when the callback itself must
return a modified schema.

## Mirroring (§11.2)

**Client evaluation is cosmetic.** Every `visibleWhen`, `requiredWhen` and
`disabledWhen` is re-evaluated server-side against the submitted state, on every
submit, with no trusted flags on the wire. A bypassed UI cannot change what is
validated or what is written.

The state the rules see is the **R1 evaluation state**: declared field defaults
merged *under* the submitted data, so the submission wins. One pass, no
fixpoint — rule outcomes never feed back into the state, which is precisely what
lets the client and the server agree.

```ts
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(s: SchemaBuilder): SchemaComponent[] {
return [
  s.section('Publication').components([
    F.select('status')
      .options({ draft: 'Draft', review: 'In review', published: 'Published' })
      .required(),

    // Conditional requiredness → a complementary `vine.group` pair, always.
    F.datetime('publishedAt').requiredWhen(when('status').is('published')),

    // Visibility → a bucketed `vine.group`; hidden means absent, not empty.
    F.textarea('subtitle').nullable().visibleWhen(when('status').in(['review', 'published'])),

    // A composite rule is no different: the same pair, one JsonLogic predicate.
    F.number('readingMinutes')
      .nullable()
      .requiredWhen(when.all([when('status').is('published'), when('featured').isTruthy()])),

    // R5: a disabled input is never required and never filled.
    F.text('editorNote').requiredWhen(when('status').is('review')).disabledWhen(when('status').is('draft')),
  ]),
]
  }
}
```

### The compiled shape

Two distinct compilations come out of that schema, and which one a field gets
depends only on *whether* its rule is conditional — never on the rule's shape,
never on the field's type.

```ts
import vine from '@vinejs/vine'
import type { FieldContext, SchemaTypes } from '@vinejs/vine/types'
import { applyValidationModifiers } from '@adonia/core'

/** How a group predicate reads the R1 state the validator carries in meta. */
const statusIn = (field: FieldContext, values: readonly string[]): boolean => {
  const state = field.meta['adonia.state'] as Record<string, unknown> | undefined
  return values.includes(String(state?.['status'] ?? ''))
}

/** The compiler's `build.required(meta)` and `build.optional(meta)`. */
const required = (base: SchemaTypes): SchemaTypes =>
  applyValidationModifiers(base, { mode: 'create', optional: false, nullable: false })
const optional = (base: SchemaTypes): SchemaTypes =>
  applyValidationModifiers(base, { mode: 'create', optional: true, nullable: false })

const compiled = vine
  .object({
// 1. unconditional
status: vine.enum(['draft', 'review', 'published']),
  })
  // 2. conditional requiredness → a complementary pair inside ONE group
  .merge(
vine
  .group([
    vine.group.if((_data, field) => statusIn(field, ['published']), {
      publishedAt: required(vine.date()),
    }),
    vine.group.if((_data, field) => !statusIn(field, ['published']), {
      publishedAt: optional(vine.date()),
    }),
  ])
  .otherwise(() => {})
  )
  // 3. one single-conditional group per DISTINCT visibility rule
  .merge(
vine
  .group([
    vine.group.if((_data, field) => statusIn(field, ['review', 'published']), {
      subtitle: optional(vine.string().trim()),
    }),
  ])
  .otherwise(() => {})
  )
```

Each piece is forced by measured Vine behaviour (the conditional-validation contract):

- **The prune step comes first.** `vine.group` is closed-world: a key present
  while its group's condition is false trips a root-level `unionGroup` error
  rather than being dropped. So hidden keys are deleted server-side *before*
  Vine runs. Prune + group + no-op `otherwise` together deliver "hidden ⇒
  neither validated nor present in the output".
- **Groups are bucketed by the exact visibility rule.** Conditionals inside one
  group are first-match-wins alternatives, so two `group.if` entries sharing a
  condition silently drop the loser's keys — measured data loss. One
  single-conditional group per distinct rule, always.
- **`.otherwise(() => {})` on every group.** "No condition matched" is the
  normal hidden case, not an error.
- **Predicates read the R1 state from validator meta**, under the key
  `adonia.state`, not from Vine's own data view — which has already been pruned
  and would disagree about a rule referencing a hidden key.
- **`disabledWhen` folds *into* the requiredness rule.** A statically disabled
  field is never required; otherwise the emitted rule is
  `and(requiredWhen, not(disabledWhen))`. It is a conjunction rather than a
  second sibling condition so the compiled schema and `isRequiredFor` cannot
  drift.

**Every** conditionally required field takes that pair — simple equality
included. Vine's own `requiredWhen(field, '=', value)` fits the simple shape and
is still deliberately unused: it resolves the sibling through `field.parent`,
i.e. the *pruned* request body, with no defaults merged and hidden keys already
deleted, and inside a repeater it looks the sibling up in the ITEM rather than
the form. Every other §11.2 decision reads the R1 state, so the two disagreed
whenever the condition field was defaulted, hidden, or lived in the parent form
of a repeater row — and always in the unsafe direction: `requiredFor()` said
required while Vine accepted the submission. One rule evaluated in one place is
worth more than a native fast path.

Inside a repeater the same machinery runs one level down, per item, with the
item as the `./` row scope. Visibility is a property of the row, not of the
column: a child hidden in item 1 disappears from item 1 and stays in item 0.

### Hidden means gone

A field hidden by the submitted state is excluded from **both** the validator
and the fill set. Not "validated as optional", not "written as `null`" —
absent. `keysFor(state)` will not list it, `fillFor(state)` will not list it,
`prune(data)` deletes it, and a forged value for it never reaches the model.

## Mass assignment (§19)

The fill set is the exact list of model attributes one submission may write, and
it is derived from the compiled schema for that submission's `(mode,
visibility-state)` — not from a static allowlist.

```ts
import { compileFillKeys } from '@adonia/core'

const fillKeys = compileFillKeys(resource, 'edit', { status: 'draft' }, ctx, record)
```

`fillableKeys(metas, state, mode?, scope?)` is the predicate, and
`compileFillKeys` / `CompiledValidator.fillFor` both call it so the two surfaces
cannot drift. A key is fillable when **all four** hold, each verified against
the field's own meta:

| Condition | Source |
| --- | --- |
| not `virtual()` | `meta.virtual` — `field.isVirtual` or `props.virtual` |
| not read-only for this mode | `meta.readonlyOn.includes(mode)` — from `readonlyOn('edit')` |
| visible for the state (R3) | `isVisibleFor` — `visibleWhen` against the R1 state |
| not disabled for the state (R5) | `isDisabledFor` — static `disabled()`, else `disabledWhen` |

The `readonlyOn` narrowing only applies when a `mode` is passed; the unit-test
and offline shapes omit it deliberately.

Then three functions do the writing, in order:

- **`applyFill(record, payload, fillKeys)`** — the only write path from
  validated input to the model. It iterates `fillKeys`, never the payload, so a
  forged attribute is not merely rejected but never *read*. A fill key absent
  from the payload leaves the attribute untouched (create: the model default;
  edit: the prior value).
- **`dehydrateFill(resource, payload, fillKeys, ctx, state?, mode?, record?)`**
  — runs each fill-set value through its field type's `dehydrate` (§7.2), so the
  model receives storage shapes: a Luxon `DateTime` instead of an ISO string, a
  number instead of a numeric string, a sqlite `0`/`1` instead of a boolean.
  Mappers may be async, so conversion is sequential and awaited. Inside a
  repeater the §19 guard re-runs per row before children are converted, so a
  child disabled in one row is dropped from that row only, and the author's own
  `dehydrate()` on the repeater runs *last*, over the already-cleaned array.
- **`compileFillKeys`** — the standalone derivation, for code that needs the set
  without a validator.

Two more exclusions are worth stating because they are not obvious. A field
gated by `canSee` / `requiresAbility()` never reaches `FieldMeta` at all when a
`ctx` is supplied (R2), so it is outside the fill set by construction rather
than by filtering. And a `sets` derivation whose target is *not* fillable is
computed but discarded — a hidden or disabled target must not re-enter through
the back door of its own derivation.

## `sets` transforms

`sets` derivations are a client convenience — "slugify the title as I type" —
but a client that never ran them still submits, and a hostile one submits
whatever it likes for the target. So the server recomputes every entry from the
submitted sources and overlays the result onto the validated output (R6). A
tampered `slug` loses.

```ts
F.text('title').live().sets('slug', 'slugify')
F.markdown('body').live(500).sets('readingMinutes', 'fn:readingTime')
```

Four transforms are built in, and each must agree character-for-character with
the client engine's copy — the fixture corpus is the shared oracle:

| Name | Behaviour |
| --- | --- |
| `slugify` | lowercase, trim, non-alphanumeric runs → single `-`, dashes trimmed off both ends |
| `lowercase` | `String(value ?? '').toLowerCase()` |
| `uppercase` | `String(value ?? '').toUpperCase()` |
| `copy` | identity — the `source`/`if` options carry the meaning |

Anything else is `fn:<name>`, resolved from the server registry:

```ts
registerTransform('readingTime', (value) => {
  if (typeof value !== 'string') return 0
  const words = value.trim().split(/\s+/).filter((word) => word.length > 0).length
  return Math.max(1, Math.ceil(words / 200))
})
```

Register at boot — a provider `boot()` hook or a plugin. The name may be written
with or without the `fn:` prefix, and colliding with a built-in throws
`InvalidConfigException` rather than shadowing it. A transform takes
`(value, state)`: the whole evaluation state is passed so an aggregation can
read siblings, for instance summing a `price` column across a repeater's rows.
Transforms MUST be pure — they run on every submit, and the client runs its own
copy on every keystroke.

`source` defaults to the declaring field and accepts dotted paths into JSON
columns and repeater rows; `if` guards the entry. Entries run in declaration
order across fields, in **one pass**: a later derivation reading an earlier
target sees the submitted value, not the derived one. That is the same
no-fixpoint discipline rule evaluation follows, and it is what keeps client and
server in step.

**An unresolvable `fn:*` fails compilation.** `assertTransformsResolvable` runs
inside `compileValidator` (and inside descriptor compilation), so a typo
surfaces on whichever comes first — rendering the form or submitting it — with a
message naming the resource, field, target, transform and the registered names.
`deriveSets` still fails closed if it is ever reached with an unresolved name:
skip the derivation, warn in dev, never `eval`. `registeredTransforms()` lists
what is registered, and `resetTransforms()` clears the `fn:*` registry for test
hygiene.

## Errors

Vine throws `E_VALIDATION_ERROR` carrying a `SimpleError[]`.
`toInputErrorsBag(errors)` folds it into the Inertia `inputErrorsBag` shape —
`Record<fieldPath, message>`, **first error per field wins**:

```jsonc
{
  "title": "The title field must be defined",
  "items.1.qty": "The qty field must be at least 1",
  "items.2.title": "The title field must have at least 3 characters"
}
```

There is no path rewriting anywhere in that fold (the native Vine path contract). Vine
already emits dot-joined paths for nested and array fields, including
arbitrarily deep ones like `items.0.children.1.name`, so the mapping is a pure
fold and a repeater needs no special handling on either side.

From there the bag reaches the form unchanged: the resource controller flashes
it under the session key `inputErrorsBag` and redirects back, Inertia shares it
as the page's `errors` prop, and `useFormEngine` unwraps it — accepting both the
named-bag and the flattened wire shapes — into `controller.error` on each field.
A rejected login flashes the identical bag from `AuthController`, so a failed
save and a failed sign-in are indistinguishable in shape. Unknown keys are
stripped rather than reported, at the top level and inside repeater items alike.

## Testing a schema's validation

`@adonia/core/testing` exposes the submit pipeline as a structured result rather
than an exception:

```ts
import { validateWith } from '@adonia/core/testing'

async function check(): Promise<void> {
  const result = await validateWith(resource, 'create', {
title: 'Hello',
status: 'draft',
publishedAt: '2026-01-01T00:00:00.000Z',
  })

  // `publishedAt` is not required for a draft, and a field hidden by the
  // submitted state would be absent from `result.output` entirely.
  console.log(result.outcome, result.errors, result.output)
}
```

`validateWith` runs prune → Vine → `sets` re-derivation and returns
`{ outcome, errors, output }`, with `errors` already in `inputErrorsBag` shape.
For the mirroring invariants themselves, `packages/core/tests/mirroring.spec.ts`
runs the whole fixture corpus in `docs/reactivity/fixtures/` — every case, in
both modes unless the case pins one — asserting exact validator, required and
fill key sets. The `@adonia/ui` parity suite reads the same files, which is how
the two evaluators are held to one answer.

Source: https://adonia.pages.dev/guide/validation/index.mdx
