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 already1so pre-release clients can reject incompatible payloads. After 1.0, required keys and semantics follow semver-major, while additive optional keys and open registrytype/propsvalues may ship in a minor release without changingprotocolVersion.
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:
- No functions, no secrets. The tree must survive
JSON.stringifyunchanged. keyis flat per form, never namespaced by layout. Atext-inputwithkey: "slug"insidegrid > asideaddresses the same state slot as one insidegrid > section. Layout nesting is purely visual;reactive.sets[].targetandvisibleWhenvarlookups resolve against the flat state map.- Omission, not hiding. A node failing
canSee/visibility-at-compile is absent from the tree (not marked hidden). There is novisible: falseflag on the wire. - Root convention.
descriptor.schemais always a single layout node (type: "grid"),key: null. Form/detail trees never have a bare array root. columnSpanis a prop on nodes inside acolumns(n)layout; value isnumberor the string"full". AcolumnSpanequal to the parent’scolumnsmeans full width (e.g.bodywithcolumnSpan: 2insidesection.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 |
optionsresponse:{ 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 neithersearchable()norpreload()there is no other source for its list. Apreload()ed field additionally ships its initial page inline asprops.options; the client still usesurls.optionsfor searching beyond that page. - A
refetch(§5) is a reference INTO this map, so any field declaringdependentOptions()carriesurls.optionstoo. A node whosereactive.refetchnames an absent entry is refused at compile time withE_ADONIA_INVALID_CONFIG. uploadreturns a temp Drive key; the field’s form state dehydrates to that key string. The server never accepts inline file data instate.
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):
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;
varpaths address the flat form-state map. refetch.urlis a reference ('urls.options'), not a URL string — the client resolves it against the same node’surlsmap. Arefetchon a node lacking the referencedurlsentry is a compile error server-side and a dev warning client-side.sets[].targetis a flat state key; execution order is document order of the array.visibleWhenfailing client-side hides but keeps state (draft safety);canSeefailing 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, andrefetchSchemaare 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 arenullor omitted — fixtures use explicitnullfor 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-toholds the foreign id;belongs-to-manyan id array. - State hygiene invariant:
stateMUST NOT contain a key for which no node exists indescriptor.schema. WhencanSeeremoves 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:
statecarriesitems(an array of item objects), neveritems.2.title. A state key for a repeater child would violate §7 state hygiene, since no top-level node claims it.items.2.titleIS the canonical runtime path: it is what a 422inputErrorsBagkey looks like (the server does no rewriting) and what the client builds from its row cursor when binding an input.reactiverules on a child resolve./-prefixedvarpaths 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)
canSee/record-dependent visibility runs at compile time; failing nodes are absent from the tree and fromstate(§7).- Record-dependent subtrees bypass the descriptor cache per TECH_SPEC §9.4; the wire format does not change between cache hit/miss.
descriptorcontent for a given(resource, mode)is identical across users except wherecanSee/abilities differ — i.e. all user-variance flows through node omission,abilities, and per-recordcan. 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 |