---
title: "Adonia Descriptor Wire Protocol — v1"
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.

# Adonia Descriptor Wire Protocol — v1

**Status:** Pre-release candidate for Adonia 1.0 · **protocolVersion:** `1`
**Normative sources:** TECH_SPEC §9 (envelope, node, reactivity, caching), §10.1 (routes), §13.2 (registry); ARCHITECTURE §9 (serialization boundary); `docs/rfcs/plugin-registry-abi.md`.
**Contract fixtures:** `fixtures/protocol/` — every normative statement below is instantiated by at least one fixture and validated by `packages/core/protocol/v1.schema.json`.

> The repository is still working toward the 1.0 release and a frozen protocol v1; its
> packages remain `0.0.1-alpha.0`. The literal version is already `1` so pre-release
> clients can reject incompatible payloads. After 1.0, required keys and semantics follow
> semver-major, while additive optional keys and open registry `type`/`props` values may
> ship in a minor release without changing `protocolVersion`.

---

## 1. Envelope (`adonia` page prop)

Every Adonia Inertia page receives a shared `adonia` prop, injected by
`AdoniaInertiaMiddleware.share()` and identical across all pages of a panel:

```ts
interface AdoniaEnvelope {
  adonia: {
protocolVersion: 1                       // literal; clients MUST hard-fail on mismatch
panel: {
  id: string                             // route-name segment: adonia.<id>.*
  brand: { name: string; logoUrl: string | null; accent: string }
  locale?: string                        // active BCP-47 locale
  messages?: Record<string, string>       // complete catalog; English fallback applied
  direction?: 'ltr' | 'rtl'
  navigation: NavNode[]
  user: { id: number | string; name: string; email: string; avatarUrl: string | null } | null
  urls: { dashboard: string; search: string; logout: string }
  tenant?: {
    id: string | number
    label: string
    options?: { id: string | number; label: string; url: string }[]
  }
}
viewer?: { id: string | number; tenant: string | number | null }
flash: { success?: string; error?: string }
  }
}
```

```ts
type NavNode =
  | { type: 'item'; label: string; icon: string | null; url: string; active: boolean }
  | { type: 'group'; label: string; children: NavNode[] }
```

`viewer` is an optional, privacy-minimized partition key for account-synced preferences;
it is omitted for guests and principals without a scalar identity.

The locale fields are optional additive v1 fields for compatibility with clients built
before framework i18n shipped. When present, `messages` is the complete browser-safe
catalog for `locale`, including host overrides with English fallback already applied;
clients do not merge a second fallback catalog. `direction` is derived from that locale.
`tenant` is omitted outside a tenant-scoped panel. Its optional `options` are
server-authorized switcher destinations, and each `url` is an absolute tenant-aware URL;
an absent `options` is not an empty or unrestricted tenant directory.

Page-specific props sit **beside** `adonia` at the top level of the page-props object:

| Key | Pages | Notes |
|---|---|---|
| `descriptor` | all resource pages | `ResourceDescriptor` (§3); shared via `inertia.always()` |
| `records` | index | partial-reloadable (`only: ['records']`, §10.2) |
| `record` | edit, detail | serialized row incl. per-record `can` (§6) |
| `state` | create, edit | initial `FormState` (§7) |
| `parent` | nested resource pages | `NestedParentContext` below; also present as `descriptor.parent` |

**Fixture:** `fixtures/protocol/envelope.json`. Each page fixture (`post.*.json`) contains
its page-specific keys plus a compact `adonia.protocolVersion` self-identification stamp.
A full page-props object uses the complete `adonia` block from `envelope.json` and the
remaining page-specific keys; the compact stamp does not replace the shared envelope.

## 2. Descriptor node

```ts
interface DescriptorNode {
  type: string                    // registry key (§8)
  key: string | null              // state key for state-bearing nodes; null for layout/display-chrome
  props: JsonObject               // component-specific; JSON-serializable ONLY — open set (§10)
  reactive?: ReactiveSpec         // §5 — top-level sibling of props, NEVER inside props
  urls?: Record<string, string>   // capability URLs (§4)
  children?: DescriptorNode[]     // layout nesting; omitted (not empty) for leaf nodes
}
```

Normative rules:

1. **No functions, no secrets.** The tree must survive `JSON.stringify` unchanged.
2. **`key` is flat per form, never namespaced by layout.** A `text-input` with
   `key: "slug"` inside `grid > aside` addresses the same state slot as one inside
   `grid > section`. Layout nesting is purely visual; `reactive.sets[].target` and
   `visibleWhen` `var` lookups resolve against the **flat** state map.
3. **Omission, not hiding.** A node failing `canSee`/visibility-at-compile is **absent**
   from the tree (not marked hidden). There is no `visible: false` flag on the wire.
4. **Root convention.** `descriptor.schema` is always a single layout node
   (`type: "grid"`), `key: null`. Form/detail trees never have a bare array root.
5. **`columnSpan`** is a prop on nodes inside a `columns(n)` layout; value is
   `number` or the string `"full"`. A `columnSpan` equal to the parent's `columns`
   means full width (e.g. `body` with `columnSpan: 2` inside `section.columns(2)`).

## 3. Resource descriptor

```ts
interface ResourceDescriptor {
  resource: {
slug: string                                  // 'posts'
labels: { singular: string; plural: string }
abilities: Record<Ability, boolean>           // resource-level; per-record §6
  }
  parent?: NestedParentContext
  mode: 'index' | 'create' | 'edit' | 'detail'
  schema?: DescriptorNode                         // create/edit/detail trees (grid root)
  table?: TableDescriptor                         // index mode
  actions?: ActionDescriptor[]                    // v1: documented shape open, see §10
  lenses?: { slug: string; label: string }[]
  savedViews?: SavedViewsDescriptor                 // index capability; additive
}

interface NestedParentContext {
  relation: string
  resource: {
slug: string
labels: { singular: string; plural: string }
  }
  record: { id: string | number; title: string }
  urls: { index: string; show: string; children: string }
}
```

`parent` is an additive v1 context, omitted for ordinary resources. On nested pages the
same value is supplied both as the page-level `parent` prop and as
`descriptor.parent`: the former lets packaged page chrome build breadcrumbs without
unwrapping an always-prop, while the latter keeps descriptor consumers self-contained.
`relation` is the owning relation on the parent model; all three URLs are server-built
and preserve the active panel host, tenant and parent scope.

```ts

interface TableDescriptor {
  columns: ColumnDescriptor[]
  filters: DescriptorNode[]                       // filter nodes, see type keys §8
  defaultSort: { column: string; direction: 'asc' | 'desc' } | null
  searchPlaceholder: string | null
  perPageOptions: number[]
  pagination: 'offset' | 'cursor'                 // the cursor pagination contract (the cursor contract)
}

interface ColumnDescriptor {
  type: string                                    // cell registry key: text|badge|relation|date|count|boolean|…
  key: string                                     // row key verbatim, may be dotted: 'author.fullName'
  props: JsonObject                               // label, sortable, searchable, link, colors, toggleable, defaultHidden, …
}
```

```ts
interface SavedViewsDescriptor {
  views: Array<{
id: string | number
name: string
shared: boolean
default: boolean
state: {
  search: string | null
  filters: Record<string, JsonValue>
  sort: Array<{ column: string; direction: 'asc' | 'desc' }>
  columns: string[] | null
  perPage: number | null
}
  }>
  endpoints: {
list: string
create: string
rename: string
delete: string
setDefault: string
  }
  canManageShared: boolean
}
```

`savedViews` is omitted when persistence is unavailable. Its rows are already scoped by
panel, resource and tenant: an authenticated operator receives only their own personal
views plus shared views. Shared creation and mutation require the resource-level
`manageSavedViews` ability; `canManageShared` gates those affordances but never replaces
server authorization. Mutation endpoint templates contain `:id`, which clients replace
with the percent-encoded view identifier. Applying a view replaces the saved table-state
subset and returns to the first page; cursors and the soft-delete scope are intentionally
not persisted.

**Dotted column keys.** Row objects (§6) are flat maps keyed by the column `key`
*verbatim* — a column `key: "author.fullName"` reads `row["author.fullName"]`, never a
nested lookup. Clients must not split on `.`.

**Reserved row keys.** `id` and `can` belong to the row envelope (§6), so no column may
claim either. The server rejects such a table at derivation with
`E_ADONIA_INVALID_CONFIG`; a client never has to disambiguate.

**Temporal encoding.** A `date` column MAY carry `props.serializeAs` —
`'iso' | 'iso-date' | 'millis' | 'seconds'`, default `'iso'` when absent — telling the
client how to parse the value it receives. `props.format`/`props.since` remain purely
presentational: locale and timezone rendering are the client's, never evaluated
server-side. (Additive under §10, like the toggle keys below.)

**Column toggles.** Every column carries `props.toggleable` and `props.defaultHidden`,
and `table.columns` always lists the FULL set — including default-hidden columns — so the
toggle menu can render them. The client persists the user's selection in `localStorage`
under `adonia:<panel>:<resource>:columns` (a JSON array of column keys) and replays it as
`?columns=a,b,c`; the server renders and serializes exactly that subset, so a hidden
aggregate column costs no sub-query. With no `?columns=`, the default set is every column
whose `defaultHidden` is false. (`props` remains the open, additive-only surface of §10 —
these two keys are documented, not frozen.)

## 4. Capability URLs

Every dynamic server interaction is a URL under `node.urls`, built server-side with
`urlFor` (therefore domain- and tenant-aware). v1 defines three capabilities:

| Capability key | Route name (`adonia.<panelId>.` prefix) | Pattern | Used by |
|---|---|---|---|
| `options` | `field.options` | `GET /:resource/field/:field/options?q=&values=` | `belongs-to`, `belongs-to-many`, `relation-filter` (filter key as `:field`) |
| `upload` | `field.upload` | `POST /:resource/field/:field/upload` (multipart) → `{ key, url }` | `file`, `image` |
| `refetch` | *not a URL* — see §5 | indirection into `urls.options` | dependent selects |

- `options` response: `{ results: [{ value: string | number; label: string }] }`,
  `Cache-Control: no-store` (§10.1). `values=` is the batched hydrate path for
  preselected values; `q=` is the search path.
- A relation field ALWAYS carries `urls.options`: with neither `searchable()` nor
  `preload()` there is no other source for its list. A `preload()`ed field
  additionally ships its initial page inline as `props.options`; the client still
  uses `urls.options` for searching beyond that page.
- A `refetch` (§5) is a reference INTO this map, so any field declaring
  `dependentOptions()` carries `urls.options` too. A node whose `reactive.refetch`
  names an absent entry is refused at compile time with `E_ADONIA_INVALID_CONFIG`.
- `upload` returns a temp Drive key; the field's form state dehydrates to that key
  string. The server never accepts inline file data in `state`.

**Fixture exercise:** `post.form.json` → `author` node (`urls.options`), `cover` node
(`urls.upload`).

## 5. Reactivity wire placement

Reactivity lives in the node's top-level `reactive` block (never in `props`):

```ts
interface ReactiveSpec {
  live?: { debounceMs: number }                       // re-run dependents after local edits
  visibleWhen?: JsonLogicRule                         // client-side, cosmetic (§11.2 mirror server-side)
  requiredWhen?: JsonLogicRule                        // client-side, mirrored in validator
  sets?: { target: string; transform: 'slugify' | 'uppercase' | 'copy' | `fn:${string}` }[]
  refetch?: { url: 'urls.options'; withState: string[] }   // dependent options
}
```

- The closed JsonLogic grammar is defined below. This protocol owns its exact wire
  placement: a rule is a plain JSON value; `var` paths address the flat form-state map.
- `refetch.url` is a *reference* (`'urls.options'`), not a URL string — the client
  resolves it against the same node's `urls` map. A `refetch` on a node lacking the
  referenced `urls` entry is a compile error server-side and a dev warning client-side.
- `sets[].target` is a flat state key; execution order is document order of the array.
- `visibleWhen` failing **client-side** hides but keeps state (draft safety);
  `canSee` failing **server-side** removes the node and its state (§7). These are
  different mechanisms and must not be conflated.

**Fixture exercise:** `post.form.json` → `title` (`live` + `sets` slugify), `publishedAt`
(`visibleWhen` `{"===":[{"var":"status"},"published"]}`).

> Grammar note: the closed operator set is **strict** — `===`/`!==`, never loose `==`.
> `disabledWhen`, `sets[].source`, and `refetchSchema` are wire-additive and do not
> change the placement contract here.

## 6. Records, rows, and per-record `can`

Index pages ship `records` as a paginated envelope mirroring Lucid's paginator
serialization; the frozen subset of `meta` is:

```ts
records: {
  data: SerializedRow[]
  meta: { total: number; perPage: number; currentPage: number; lastPage: number; from: number; to: number }
}
```

**Cursor mode** (`table.pagination === 'cursor'`) replaces that meta wholesale —
it does not extend it:

```ts
meta: { perPage: number; nextCursor: string | null; prevCursor: string | null; hasMore: boolean }
```

`total`, `lastPage`, `currentPage`, `from` and `to` are ABSENT: every one of them needs a
`COUNT(*)` or an absolute row offset, and not issuing that count is the whole point of the
mode. Clients discriminate on `table.pagination`, or structurally on the presence of
`nextCursor`; a sentinel `0`/`-1` is never emitted, because a client cannot tell a
sentinel from a real value. Navigation is next/prev — `?cursor=<token>`, mutually
exclusive with `?page=` — and the tokens are opaque. Clients MUST NOT parse them.

```ts
type SerializedRow = {
  id: string | number
  [columnKey: string]: /protocol/JsonValue        // flat, dotted keys verbatim (§3)
  can: Record<Ability, boolean>         // per-record authorization projection
}
```

**Per-record `can` convention.** Every serialized record — index rows, edit/detail
`record` — carries `can`: the abilities the *current user* holds over *that record*.
The client uses it to enable/disable row actions and links; it is never authoritative
(all routes re-authorize, §12). Abilities that are uniform for the whole resource MAY
be read from `descriptor.resource.abilities` instead; per-record `can` is the
override-capable projection.

**Fixture exercise:** `post.index.json` rows (one deletable, one not);
`post.edit.authorized.json` vs `post.edit.restricted.json` (`record.can` differs).

## 7. Form state

`state` is a flat `Record<string, JsonValue>` keyed by node `key`:

- **Create mode:** defaults only (`status: "draft"`); absent keys are `null` or omitted —
  fixtures use explicit `null` for every node key (D-workstream: either is accepted,
  explicit-null is canonical).
- **Edit mode:** hydrated from the record. File/image keys hold the stored Drive key
  string. `belongs-to` holds the foreign id; `belongs-to-many` an id array.
- **State hygiene invariant:** `state` MUST NOT contain a key for which no node exists
  in `descriptor.schema`. When `canSee` removes a node server-side, its state key is
  removed in the same compile pass. The client drops unknown state keys and never
  submits them (mass-assignment guard).

State-boundary nodes are the exception to “flat per form”: the boundary itself has one
top-level key, while each child node key is flat **within one item**. For
`has-many`, each existing item additionally carries the reserved boundary-owned key
`id` (`string | number`); it is not a child descriptor node. New items omit `id`.
The server preserves this key through validation/fill to authorize and update an owned
child, rejects an id owned by another parent, creates id-less items, and deletes omitted
owned items. Clients MUST preserve an existing item's `id` unchanged and MUST NOT
synthesize one for a new item.

## 8. Type keys and unknown types

Registry namespaces — `r.field`, `r.cell`, `r.filter`, `r.widget`, `r.page`, and
`r.slot` — are separate maps. A node's `type` is resolved against the map for
its syntactic position (schema child → field/layout; table column → cell;
table filter → filter).

v1 built-in keys used by the fixtures:

| Position | Keys |
|---|---|
| layout | `grid`, `section`, `aside` |
| field | `text-input`, `textarea`, `rich-text`, `select`, `datetime`, `image`, `repeater`, `belongs-to`, `belongs-to-many` (full set: §7.3) |
| display | `text-entry`, `badge-entry`, `image-entry`, `relation-entry`, `repeater-entry`, `html` |
| cell | `text`, `badge`, `relation`, `date`, `count`, `boolean` |
| filter | `select-filter`, `relation-filter`, `date-range-filter`, `ternary-filter` |

**Detail projection** (§7.4): in `detail` mode each field maps to its `displayType` —
`text-input → text-entry`, `image → image-entry`, `select → badge-entry` (when options
have colors), `belongs-to(-many) → relation-entry`, `rich-text → html`,
`datetime → text-entry` with a `format` prop. Layout nodes pass through unchanged;
`reactive` blocks are dropped in detail mode (visibility already resolved at compile).

A projected node carries the props its entry needs to render a value it receives RAW
(§7: `record`/`state` hold what is stored). Beyond chrome, that means the value→
presentation maps `colors`, `icons` and `labels` for the choice fields, `resource`/
`optionLabel`/`multiple` for the relation fields, and `disk`/`visibility`/`signedUrl`
for the media fields. Input-only props (`placeholder`, `default`, `options`,
`searchable`, upload rules) and validation props are dropped — a detail entry renders a
value, it never accepts one. Each field type declares its own set
(`Field.detailProps()`), so a new type cannot silently lose its presentation data.
All of these are additive `props` keys under §10.

**State boundaries** (§7.3 `repeater`; additive under §10, no skeleton change). A node
may carry BOTH a `key` and `children`. That combination — and only that combination —
means the node owns a nested state map: its children key an ITEM of the node's value,
not the form. Rule 2 above still holds unchanged, because it is about LAYOUT: a
`text-input` inside `grid > aside` keys `slug`, and a `text-input` inside a repeater
keys `title` *within the row*. Consequences for clients:

- `state` carries `items` (an array of item objects), never `items.2.title`. A state
  key for a repeater child would violate §7 state hygiene, since no top-level node
  claims it.
- `items.2.title` IS the canonical runtime path: it is what a 422 `inputErrorsBag` key
  looks like (the server does no rewriting) and what the client builds from its row
  cursor when binding an input.
- `reactive` rules on a child resolve `./`-prefixed `var` paths against the ROW and
  every other path against the root form state (the repeater-scoping contract).
- A client walking the tree for state keys MUST stop descending at the first keyed
  node; walking through it produces phantom top-level keys.

**Unknown-type behavior (frozen).** A node whose `type` resolves to no registered
component MUST render a visible `<UnknownComponent>` warning placeholder in development
(showing the offending `type` and `key`), render nothing in production, log a console
warning in both — and MUST never throw. This is what makes plugin client-halves
optional at runtime (§16.3).

## 9. Compilation invariants (normative, tested by fixtures)

1. `canSee`/record-dependent visibility runs at compile time; failing nodes are absent
   from the tree **and** from `state` (§7).
2. Record-dependent subtrees bypass the descriptor cache per TECH_SPEC §9.4; the wire
   format does not change between cache hit/miss.
3. `descriptor` content for a given `(resource, mode)` is identical across users except
   where `canSee`/abilities differ — i.e. all user-variance flows through node
   omission, `abilities`, and per-record `can`. No user-specific props values.

## 10. Frozen vs open

| Surface | Status |
|---|---|
| Envelope shape (`adonia.protocolVersion/panel/viewer?/flash`) | **Frozen** |
| `protocolVersion: 1` literal; hard-fail on mismatch | **Frozen** |
| DescriptorNode skeleton: `type/key/props/reactive?/urls?/children?` | **Frozen** — new top-level keys are NOT additive; bump protocol |
| `props` contents per type | **Open, additive-only** — new props keys may appear in minors; clients ignore unknown props |
| `urls` capability keys beyond `options`/`upload` | **Open, additive-only** |
| ReactiveSpec key set | **Frozen** (grammar of JsonLogicRule: the reactivity contract) |
| Built-in type-key vocabulary | **Open, additive-only** — new built-ins land in minors; unknown-type rule makes this safe |
| Per-record `can` ability vocabulary | **Frozen** (TECH_SPEC §12 vocabulary) |
| `ActionDescriptor` shape | **Implemented** — compiler-emitted and fixture-validated; node skeleton frozen, `props` additive-only |
| `meta` pagination keys beyond the frozen subset | **Open, additive-only** |
| `descriptor.savedViews` | **Open, additive-only** — optional index capability; state and endpoint keys above are documented |
| Filter node `type` naming (`*-filter`) | **Frozen** for v1 built-ins |

## 11. Fixture inventory

| File | Page-props keys | Proves |
|---|---|---|
| `envelope.json` | `adonia` | §1 panel chrome: brand, nav tree, user, flash |
| `post.index.json` | `adonia`, `descriptor`, `records` | §3 table, compiled actions, and saved views (5 column kinds, 4 filter kinds, defaultSort, searchPlaceholder), §6 rows + meta + per-record `can` |
| `post.form.json` | `adonia`, `descriptor`, `state` | create-mode tree: grid root, section.columns(2), columnSpan, §4 capabilities, §5 live/sets/visibleWhen |
| `post.edit.json` | `adonia`, `descriptor`, `record`, `state` | hydrated edit state, record + `can` |
| `post.edit.authorized.json` | `adonia`, `descriptor`, `record`, `state` | admin sees `internalNotes` node + its state key |
| `post.edit.restricted.json` | `adonia`, `descriptor`, `record`, `state` | same record, non-admin: node ABSENT, state key ABSENT, reduced `can` |
| `post.detail.json` | `adonia`, `descriptor`, `record` | §8 display projection, layout preserved, reactive dropped |

Source: https://adonia.pages.dev/protocol/v1/index.mdx
