Skip to content

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:

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 }
  }
}
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

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

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.


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, …
}
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.jsonauthor node (urls.options), cover node (urls.upload).

5. Reactivity wire placement

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

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.jsontitle (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:

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:

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.

type SerializedRow = {
  id: string | number
  [columnKey: string]: 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 displayTypetext-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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close