إنتقل إلى المحتوى الرئيسي

04 · Form Schema Specification — Rasd Form Definition (RFD) v1

Purpose: The normative, property-by-property specification of the RFD v1 JSON payload that every Rasd package consumes: root, meta, settings, choiceLists, datasets, pages, logic, the base Element, every element type, localized strings, ext, naming, versioning, validation, limits and the evolution policy. Audience: Engineers building @rasd/core, the renderers, the builder and @rasd/xlsform; developers at UN/NGO organisations who author RFDs by hand, generate them from other systems, or migrate from XLSForm/Kobo/SurveyJS.

TL;DR

  • An RFD is one self-contained JSON document ($schemahttps://schemas.rasd.dev/form/v1.json, JSON Schema 2020-12). It carries layout, logic, translations, theme hints and custom payload — nothing lives outside it except datasets and media bytes.
  • The zod schemas in @rasd/core are the executable source of truth; docs/schema/rasd-form.schema.json is generated from them (z.toJSONSchema) and CI fails when the two drift. Semantics that JSON Schema cannot express (name uniqueness, references, expression parsing, cycles) live in the semantic pass of validateFormDefinition().
  • Property names are camelCase; element type values are snake_case (XLSForm-compatible where possible); custom types are x:<name>; every object node may carry ext (vendor-namespaced, opaque, always round-tripped).
  • Element name is the storage key and is form-unique within its repeat scope; groups are transparent; repeats produce object[]. Moving an element between pages/groups is non-breaking.
  • All logic is REL v1 source text in *Expr-typed properties (relevant, required, readonly, constraint, calculate, default.expr, count, when, choiceFilter, itemLabel, instanceName). No eval; dependencies are extracted statically.
  • Loading a definition runs two passes: structural (zod, ≈ JSON Schema) then semantic (linker). Errors abort; warnings are surfaced to the builder and the console. Codes are stable strings (E_*, W_*) inside a RasdError with code: "RASD_SCHEMA_INVALID".
  • Hard limits: 2 MiB per definition, 2,000 elements, 3 nested repeat levels, 10,000 inline choices per list (warn at 500 — use a dataset), 5,000 AST nodes per expression.
  • rasd: "MAJOR.MINOR": minors are additive and must-ignore-and-preserve; majors ship a converter; deprecations live ≥ 12 months. Published (id, version) pairs are immutable and identified by definitionHash.

1. Conformance, sources of truth and shared primitives

1.1 Normative language and precedence

MUST / MUST NOT / SHOULD / MAY are used as in RFC 2119. Precedence when texts disagree: 00 · Design spine §4–§5 → this document → schema/rasd-form.schema.json → prose elsewhere. A disagreement between the zod schema and this document is a bug in whichever one was edited last; the fix is a test in packages/core/src/schema/__tests__/.

1.2 The zod → JSON Schema pipeline

@rasd/core defines the schema once, in zod (packages/core/src/schema/*.ts: form.ts, element.ts, elements/*.ts, settings.ts, logic.ts, choices.ts, primitives.ts). TypeScript types are z.infer<> of those schemas and are re-exported as FormDefinition, Element, Page, ChoiceList, Dataset, Trigger, TriggerAction, LocalizedString, Expr, Ext. Nothing else in the monorepo declares these shapes by hand.

flowchart LR
Z["zod schemas<br/>@rasd/core/src/schema"] -->|z.toJSONSchema, target 2020-12| G["generated JSON<br/>+ $id, $schema, title<br/>+ deprecated, x-rasd-*"]
G -->|scripts/schema-build.ts| F["docs/schema/rasd-form.schema.json"]
Z -->|z.infer| T["TS types<br/>FormDefinition …"]
F -->|ajv 2020 in CI| E["examples/*.form.json<br/>+ fixture corpus"]
Z -->|runtime| V["validateFormDefinition()<br/>structural pass"]
V --> S["semantic pass<br/>(linker, REL parse, cycles)"]
S --> N["createFormEngine()"]

Rules for the pipeline:

  • Build: pnpm schema:build runs z.toJSONSchema(FormDefinitionSchema, { target: "draft-2020-12", io: "input", unrepresentable: "any" }), then a post-processor injects $id, $schema, title, description from zod .describe() calls, deprecated: true from .meta({ deprecated: true }), x-rasd-expr: true on every REL-typed property, x-rasd-localized: true on localized strings, and additionalProperties: true on the extension points (ext, props of x:*, appearance). Output is written with sorted keys and a trailing newline.
  • Check: pnpm schema:check (CI) regenerates into a temp file and diffs against the committed file; any difference fails the job. pnpm schema:test validates every docs/examples/*.form.json and every fixture in @rasd/testing against the JSON Schema with ajv (2020 mode) and against zod; both must agree on validity.
  • Runtime never bundles ajv (research/02 documents RJSF's AJV size problem). The JSON Schema exists for editors ($schema autocompletion), non-JS validators (Python/Java back-ends) and documentation; refinements it cannot express (uniqueness, references, REL parsing, cycles) live in the semantic pass (§15).

1.3 Shared primitives

/** string, or a map from BCP 47 locale to string. See §11. */
export type LocalizedString = string | { [bcp47Locale: string]: string };
/** REL v1 source text. Parsed at load time; never eval'd. */
export type Expr = string;
/** Custom payload, opaque to Rasd. See §12. */
export type Ext = { [vendorKey: string]: unknown };
export interface Geo { lat: number; lng: number; alt?: number; accuracy?: number; capturedAt: string }
export interface AttachmentRef {
attachmentId: string; // UUID v7, key into StorageAdapter.attachments
name?: string; mime?: string; bytes?: number; sha256?: string;
capturedAt?: string; // ISO-8601
geo?: Geo; // sidecar geotag (EXIF is stripped)
}
export interface Media { image?: LocalizedString; audio?: LocalizedString; video?: LocalizedString }
export type Severity = "error" | "warning" | "info";

AttachmentRef is the value of image, audio, video, file and signature questions (an array when props.multiple); the bytes are never inside the definition or the submission JSON.

1.4 Naming conventions

ThingConventionExample
Property namescamelCaserequiredMessage, choiceFilter
Element typesnake_case, XLSForm-compatible where one existsselect_multiple, geopoint
Custom element typex: + kebab-casex:beneficiary-lookup
Element / list / dataset / calculated name^[a-zA-Z_][a-zA-Z0-9_]*$, ≤ 64 charshh_size
Page id, trigger id^[a-zA-Z_][a-zA-Z0-9_-]*$, ≤ 64 charsintro, t-consent-no
Form id^[a-z0-9][a-z0-9_-]{0,63}$ (slug)pdm-gfd-2026
Enumerationslowercase, kebab-case when multiword"islamic-umalqura", "field-list"
Vendor keys in extreverse-DNS or org slugorg.wfp.moda, unrwa

2. Root document

PropertyTypeRequiredDefaultSemantics / validation
$schemastring (URI)SHOULDhttps://schemas.rasd.dev/form/v1.json. Ignored by the runtime; used by editors.
rasd"MAJOR.MINOR" stringMUSTRFD spec version this document was written against, e.g. "1.0". Consumer rules in §17.
idslugMUSTStable form identifier, unique per organisation; changing it creates a new form (all history detaches).
versionstringMUSTMonotonically increasing per id, ≤ 32 chars. Compared segment-wise (numeric-aware compare; segments split on ., -, _). A published (id, version) is immutable.
requires{ rasd?: string; features?: string[] }MAYHard requirements. rasd is a semver range on the spec (">=1.2"); features are type:<elementType>, fn:<function>, x:<name>, cap:<capability> (e.g. cap:encryption). A consumer that cannot satisfy them MUST refuse to open the form with E_REQUIRES_UNMET instead of degrading.
metaobjectMUST§3
settingsobjectMUST§4
choiceLists{ [name]: ChoiceList }MAY{}§5
datasetsDataset[]MAY[]§6
pagesPage[]MUST (≥ 1)§7
logic{ calculated?: Calculated[]; triggers?: Trigger[] }MAY{}§8
extExtMAY§12

Unknown root properties are a warning (W_UNKNOWN_PROPERTY) and are preserved (§17). Keys named __proto__, constructor or prototype anywhere in the document are an error (E_PROTO_KEY, research/11).


3. meta

PropertyTypeRequiredNotes
titleLocalizedStringMUSTShown in form lists and as the default page header. ≤ 200 chars per locale.
descriptionLocalizedStringMAYMarkdown-safe subset (§11.4).
tagsstring[]MAYFree-form; ≤ 32 tags, each ≤ 40 chars. Used for filtering in the host's form list.
authorstringMAYFree text or email.
createdAt / updatedAtISO-8601 UTCMAYSet by the builder. updatedAt is excluded from definitionHash (§14.1).
changelogLocalizedStringMAYHuman note for this version; the builder pre-fills it from diffDefinitions.
extExtMAY

4. settings

4.1 Core keys

PropertyTypeDefaultSemantics
defaultLocaleBCP 47MUSTFallback locale for every LocalizedString. MUST be a member of locales (E_DEFAULT_LOCALE_NOT_IN_LOCALES).
localesstring[]MUST (≥ 1)Ordered list offered to the enumerator; ≤ 20. Invalid tags → E_LOCALE_INVALID.
navigation"paged" | "scroll""paged"paged = one page per screen with Back/Next; scroll = one long screen, pages rendered as sections.
showProgressbooleantrueProgress indicator (pages done / relevant pages).
allowDraftsbooleantruefalse hides "Save draft"; autosave still writes a single recovery draft (never lose data, P5) which is discarded on finalize or explicit abandon.
autosaveMsinteger2000Autosave debounce. 0 = write on every change; values 1–249 are raised to 250.
instanceNameExprEvaluated on every change; result (≤ 200 chars) becomes submission.meta.instanceName. Non-string results are stringified.
submissionIdPrefix^[A-Z0-9]{1,8}$Prefix for the human-readable short id shown in lists (PDM-7F3K2); the UUID v7 stays the real id.
numbering"latn" | "native""latn"Digit display for inputs; display-only strings use the locale's native digits. Inputs always normalise to ASCII on save (research/13).
calendar"gregorian" | "islamic-umalqura""gregorian"Display-only; stored dates are always ISO Gregorian.
localeMeta{ [locale]: { dir?: "rtl"|"ltr"; numbering?; calendar? } }Per-locale overrides. dir defaults from the language subtag (ar, ckb, fa, ps, ur, he, sd, ug → rtl).
auditobjectsee 4.2
encryptionobjectsee 4.3
themeobjectsee 4.4
extExt

4.2 settings.audit

"audit": {
"enabled": true, // default false. Emits the ODK-compatible event vocabulary into submission.audit
"trackChanges": true, // default false. Log old/new for every value event (bind.trackChanges overrides per element)
"changeReasons": "never", // "never" (default) | "onEdit": ask for a reason when editing a finalized submission
"identifyUser": false, // include meta.userId in every event
"location": { "enabled": false, "priority": "balanced", "minSeconds": 60, "minMeters": 50 }
// priority: "no-power" | "low-power" | "balanced" | "high-accuracy" (ODK names)
}

When enabled is false, submission.audit is []; consent events (§10.12) are still recorded because they are a data-protection requirement, not telemetry.

4.3 settings.encryption

PropertyTypeSemantics
mode"none" (default) | "field" | "submission"field: values of elements with bind.sensitive: true are encrypted at rest and in the payload with the key identified by publicKeyId; the server can read everything else. submission: the whole finalized data + attachments are enveloped; the server can only store/forward (no OData, no records).
publicKeyIdstringRequired when mode ≠ "none" (E_ENCRYPTION_KEY_MISSING). Resolved by the host key provider; the definition never embeds key material.

Cryptographic details are in 16 · Security; the schema only carries the intent.

4.4 settings.theme

{ "themeId": "rasd-field", "overrides": { /* partial theme per rasd-theme.schema.json */ } }. A hint: the host's <RasdProvider theme> wins; a renderer MAY ignore the hint entirely. overrides MUST validate against the theme schema's partial form (rasd theme check). See 12 · Theming.


5. choiceLists

A choice list is a named, reusable set of options referenced by select_one, select_multiple, rank and matrix via props.list. Elements MAY instead carry props.choices[] inline for one-off lists; the object shape is identical.

interface Choice {
value: string; // ^[^\s]{1,128}$ — no whitespace (space-separated on XForm export), unique in the list
label: LocalizedString;
media?: Media; // per-language image/audio for low-literacy respondents
attrs?: { [key: string]: string | number | boolean }; // filter attributes (XLSForm extra choice columns)
ext?: Ext;
}
interface ChoiceList {
choices?: Choice[]; // inline source
source?: { type: "dataset"; dataset: string }; // dataset source (mutually exclusive with choices)
valueKey?: string; labelKey?: string; // required with source: dataset column names
filterKeys?: string[]; // dataset columns used with "=" in choiceFilter → indexed, pushed to storage
ext?: Ext;
}

Rules:

  • Exactly one of choices / source (E_CHOICELIST_SOURCE). source.dataset MUST name an entry in datasets (E_DATASET_NOT_FOUND).
  • Inline lists: W_INLINE_CHOICES_LARGE above 500 choices, E_INLINE_CHOICES_TOO_MANY above 10,000. Duplicate valueE_CHOICE_DUPLICATE.
  • Dataset lists: each row is a candidate; valueKey/labelKey MUST exist in the dataset's declared columns when declared (E_DATASET_COLUMN_UNKNOWN), else a warning at first pull. Rows are filtered by the element's props.choiceFilter; conjunctions of key = ${x} where key ∈ filterKeys are pushed to datasets.query(name, { filter }) (WHERE key = ? on SQLite, indexed on Dexie); the residual predicate is evaluated per row in JS. Lists without filterKeys over > 5,000 rows are W_FILTERKEYS_MISSING.
  • Inside choiceFilter, a bare identifier (gov_code, value, label) resolves to the candidate choice's attribute (attrs.* or dataset column); ${name} resolves to form values as usual. This is what makes XLSForm choice_filter strings (gov_code = ${gov}) parse unchanged. Grammar in 05 · Logic.
  • Unreferenced lists are W_UNUSED_CHOICE_LIST (kept; harmless).

6. datasets

Reference data pulled to the device and queried by choice lists and pulldata().

interface Dataset {
name: string; // element-name regex; unique in datasets[]
source: "server" | "inline" | "url"; // server = RSP GET /v1/datasets/{name}; url = one-shot fetch (cached); inline = rows below
keyField: string; // unique row key (ODK entity `name`); used by pulldata() default key
inline?: Array<Record<string, string | number | boolean | null>>; // only when source: inline
url?: string; // only when source: url; https only
columns?: Array<{ name: string; type?: "string" | "number" | "boolean"; label?: LocalizedString }>; // optional declared schema
minVersion?: string; // refuse to render choices from an older dataset version (validation provenance)
ext?: Ext;
}
  • inline rows: W_DATASET_INLINE_LARGE above 2,000, E_DATASET_INLINE_TOO_MANY above 10,000 — large tables belong on the server (dataset rows are versioned by hash and pulled as deltas, see 10 · Sync). Datasets are never pinned to a form version; a newer dataset is always allowed on device (research/12 §10).
  • pulldata('geo_dist', 'name', 'code', ${district}) reads column name of the row whose code equals the key; a missing row yields null. Column names referenced by pulldata are checked against columns when present (W_DATASET_COLUMN_UNKNOWN).
  • Unreferenced datasets are W_UNUSED_DATASET.

7. pages

interface Page {
id: string; // page-id regex; unique across pages
title?: LocalizedString;
description?: LocalizedString;
relevant?: Expr; // page skipped (and its values excluded on finalize) when false
elements: Element[]; // ≥ 0; a page with 0 elements is W_EMPTY_PAGE
ext?: Ext;
}

Pages MUST NOT nest. In navigation: "paged" each page is a screen and validation runs per page before Next (renderer validateOn: "page"); in "scroll" pages are headed sections. skipTo targets page ids (§8.2). At least one page must exist (E_NO_PAGES); > 200 pages is W_MANY_PAGES.


8. logic

8.1 logic.calculated[]

{ name, calculate: Expr, includeInData?: boolean, ext? } — form-scope computed values that have no position in the layout. name shares the element namespace (§13). They are recomputed like calculate elements (topological order) and referenced as ${name} in any expression or {name} in labels. includeInData defaults to false; set true to emit the value into submission.data (equivalent to a calculate element without a page). Calculated values cannot live in a repeat scope; use a calculate element inside the repeat for per-instance values.

8.2 logic.triggers[]

interface Trigger {
id: string;
when: Expr; // boolean expression
on?: "change" | "pageLeave" | "finalize"; // default "change"
once?: boolean; // default false: fire on every false→true edge; true: fire at most once per submission
actions: TriggerAction[]; // executed in order
ext?: Ext;
}
type TriggerAction =
| { type: "setValue"; target: string; value?: unknown; expr?: Expr } // exactly one of value / expr
| { type: "clearValue"; target: string }
| { type: "complete"; message?: LocalizedString } // finalize now (skips remaining pages; validation of hidden pages is skipped)
| { type: "skipTo"; page: string } // navigate to page id (paged navigation only; no-op in scroll)
| { type: "showMessage"; message: LocalizedString; severity?: Severity; blocking?: boolean } // toast (default) or modal (blocking)
| { type: "custom"; id: string; payload?: unknown }; // dispatched to host onTriggerAction(id, payload, ctx)

Semantics: triggers are edge-triggeredwhen is re-evaluated when any dependency changes and actions run when the result transitions from falsy to truthy (or on the on event while truthy). setValue writes through the engine (audit event value, source "trigger:<id>"), which may fire other triggers; the chain depth is capped at 8, then E_TRIGGER_LOOP is raised at load if statically detectable and RASD_TRIGGER_LOOP at runtime otherwise. target MUST name an existing question (E_TRIGGER_TARGET_UNKNOWN); targeting a calculate element or any element that has a calculate expression is E_TRIGGER_TARGET_CALCULATED (readonly elements are legal targets — readonly restricts the user, not logic). skipTo MUST reference a page id (E_SKIPTO_UNKNOWN_PAGE). complete after showMessage in the same action list is allowed and common ("Thank you, the interview ends here").


9. The base Element

Every node in pages[].elements (and inside group/repeat elements) has this shape. Columns: REL = accepts an expression.

PropertyTypeDefaultRELSemantics / validation
typestringMUSTOne of §10 or x:<name>. Unknown built-in-looking type → E_UNKNOWN_TYPE; unregistered x: type is legal at load, renders a placeholder (W_UNREGISTERED_CUSTOM_TYPE at render).
namestringMUSTStorage key. Regex §1.4; unique in its repeat scope; reserved names §13.
labelLocalizedStringMUST for questions and note; MAY for hidden, calculate, group, repeatMissing on a question → W_LABEL_MISSING (accessibility). Mini-Message interpolation §11.3.
hintLocalizedStringUnder the label, always visible.
guidanceLocalizedStringCollapsible help (XLSForm guidance_hint).
mediaMediaPer-language image/audio/video shown with the label. Ref rules §11.5.
requiredboolean | ExprfalseEnforced only when relevant. On containers: group → ignored (W_REQUIRED_ON_GROUP); repeat → at least props.min instances.
requiredMessageLocalizedStringrenderer default
relevantExpralways relevantFalse ⇒ hidden, not validated, value excluded from finalized data (kept in the draft).
readonlyboolean | ExprfalseDisplayed, not editable; still receives calculate, default and trigger setValue.
default{ value: T } | { expr: Expr }✓ (expr)Applied once when the submission (or repeat instance) is created — once() semantics. value MUST match the element's value type (E_DEFAULT_TYPE_MISMATCH). Not allowed on calculate (E_DEFAULT_ON_CALCULATE).
calculateExprValue is computed whenever a dependency changes; element becomes read-only. Cycles → E_CALC_CYCLE.
constraintExprEvaluated only when the value is non-empty (ODK); . refers to the element's own value. Runs on change and on finalize.
constraintMessageLocalizedStringrenderer default
validatorsValidator[][]✓ (expr)≤ 20 per element (E_TOO_MANY_VALIDATORS). Table below.
appearance{ variant?, columns?, size?, ext? }{}Presentation hints; renderers ignore unknown values (never an error). variant vocabulary per type in §10; columns 1–6; size "sm"|"md"|"lg".
bind{ sensitive?, saveIncomplete?, trackChanges?, index?, ext? }see belowStorage/behaviour flags.
propsobject{}per typeType-specific (§10). Unknown keys → W_UNKNOWN_PROPERTY; wrong types → E_TYPE_PROPS_INVALID.
elementsElement[]Only on group and repeat (E_ELEMENTS_ON_LEAF otherwise).
extExt§12

validators[]:

typeFieldsApplies to
regexpattern (JS RegExp source, u flag, ≤ 500 chars, ≤ 5k steps guard), messagestring values (text, barcode, select values)
rangemin?, max? (numbers or ISO strings) , message?number, rating, range, date/time/datetime (lexicographic on ISO)
lengthmin?, max?, message?strings; arrays (item count)
exprexpr (Expr, . = own value), messageany
customid (host validator registered on the provider), message?any; unknown id at render → W_CUSTOM_VALIDATOR_UNKNOWN, treated as pass

Every validator accepts severity ("error" default — blocks finalize; "warning" — shown, logged as audit event constraint error with severity, does not block; "info"). regex patterns are matched unanchored (JS RegExp.test); the XLSForm importer anchors patterns lacking ^…$ and emits W_REGEX_UNANCHORED because JavaRosa's regex() is anchored (research/14 §4).

bind semantics:

FlagDefaultEffect
sensitivefalseValue is PII: masked in read-only views, redacted from logs/crash reports/instanceName (W_SENSITIVE_IN_INSTANCE_NAME), field-encrypted when settings.encryption.mode = "field"; W_SENSITIVE_WITHOUT_ENCRYPTION when the mode is none.
saveIncompletetrueValue is written to the draft even while it fails constraint/validators. false keeps an invalid value in memory only (use for sensitive identifiers that must not touch disk half-typed).
trackChangesinherits settings.audit.trackChangesOld/new values in audit events for this element.
indexfalseStorage creates a queryable index on this field for list/search screens. Index columns are cleartext even under encryption → W_INDEX_ON_SENSITIVE if both set.

10. Element type catalogue

Each entry: props (type · default · notes), a complete example, stored value, validation rules, renderer notes. label/hint/base properties are omitted from examples for brevity except where they matter. "Renderer notes" are requirements shared by 06 · React and 07 · Native.

10.1 text

PropType · defaultNotes
multilineboolean · falsetextarea / multiline TextInput, auto-grow to 6 lines
format"none"|"email"|"phone"|"url" · "none"Sets keyboard/inputmode and adds a built-in format check (severity error, message overridable via validators)
maxLengthinteger · 4000Hard cap; counter shown above 80 %
maskstring# digit, A letter, * any, other chars literal (e.g. "###-####"); stored value is the raw input without mask literals
placeholderLocalizedString
{ "type": "text", "name": "hh_id", "label": { "en": "Household ID", "ar": "رقم الأسرة" },
"required": true, "props": { "mask": "AA-######", "maxLength": 9 },
"validators": [ { "type": "regex", "pattern": "^[A-Z]{2}[0-9]{6}$", "message": { "en": "Format: AB-123456" } } ],
"bind": { "index": true } }

Value string. Empty string is stored as null. Renderer: dir="auto" / first-strong alignment for free text; LTR island for mask, phone, email, url (research/13 §5).

10.2 number

PropType · defaultNotes
kind"integer"|"decimal" · "decimal"integer rejects fractions at input
min / maxnumberBuilt-in range check (error)
stepnumber · 1 (integer) / any (decimal)Stepper increment
unitLocalizedStringSuffix, e.g. { "en": "kg" }
thousandsSeparatorboolean · falseDisplay only
{ "type": "number", "name": "children", "label": "Children under 5",
"props": { "kind": "integer", "min": 0, "max": 20 }, "default": { "value": 0 } }

Value: JSON number (integer kind ⇒ no fractional part; E_DEFAULT_TYPE_MISMATCH for default.value: 1.5). Values beyond ±2^53 or > 15 significant digits are rejected at input; identifiers belong in text with a mask. Renderer: numeric keypad (inputmode="numeric"|"decimal"), ASCII normalisation of Arabic-Indic digits on save, never <input type="number"> for ids.

10.3 date / time / datetime

PropType · defaultNotes
min / maxISO string literalStatic range check; use constraint (e.g. . <= today()) for dynamic bounds
calendar"gregorian"|"hijri" · settings.calendarDisplay/picker only
variant (appearance)"picker"|"no-calendar"|"month-year"|"year"typed entry vs picker
{ "type": "date", "name": "dist_date", "label": "Distribution date",
"props": { "max": "2026-12-31", "calendar": "hijri" }, "default": { "expr": "today()" } }

Values: date"YYYY-MM-DD"; time"HH:mm[:ss]" local wall time without offset; datetime → full ISO-8601 with the device offset preserved ("2026-08-15T10:00:00+03:00") so XForm export never has to invent one. Question values are the one deliberate exception to the UTC convention of 00 · Design spine §12, which continues to govern system timestamps (meta.*, audit events, createdAt/updatedAt): respondent-entered dates and times are never converted across zones, matching ODK XForms serialization (research/14). Comparisons in REL are lexicographic on the ISO string (values with mixed precision or offsets need dateDiff()05 · Logic). Renderer: pickers follow dir; Hijri labels are RTL Arabic even in an English UI; digits per settings.numbering.

10.4 select_one

PropType · defaultNotes
listchoiceLists keyXOR with choices (E_SELECT_SOURCE); MUST name a key in choiceLists (E_LIST_NOT_FOUND)
choicesChoice[]inline
choiceFilterExprPredicate over candidate choices (§5)
searchboolean · auto (> 12 choices)Searchable list; Arabic-normalised matching
other{ enabled: boolean; label?: LocalizedString; value?: string } · disabledAdds a free-text option. value defaults to "other"; the typed text is stored in the reserved sibling key <name>_other (XLSForm or_other convention)
randomizeboolean · falseShuffle order per submission (seeded by submission id, stable across re-renders)
appearance.variant"radio"|"dropdown"|"chips"|"buttons"|"likert" · radio (≤ 6) / dropdown (> 6)
{ "type": "select_one", "name": "district", "label": { "en": "District", "ar": "المديرية" },
"props": { "list": "district", "choiceFilter": "gov_code = ${governorate}", "search": true },
"required": true }

Value string (a Choice.value); a value not in the (unfiltered) list is a constraint failure at finalize (W_VALUE_NOT_IN_LIST in drafts, e.g. after a dataset update). Renderer: touch targets ≥ 48 px, radiogroup semantics, <bdi> around Latin labels in RTL lists.

10.5 select_multiple

As select_one plus minSelected, maxSelected (integers), exclusive: string[] (choosing one of these values clears the others, e.g. ["none"]).

{ "type": "select_multiple", "name": "coping", "label": "Coping strategies used",
"props": { "list": "coping", "maxSelected": 3, "exclusive": ["none"] },
"appearance": { "variant": "chips", "columns": 2 } }

Value string[] in selection order; [] is stored as null on finalize. selected(${coping}, 'sell_assets'), countSelected() operate on it.

10.6 rank

Props: list / choices (XOR). Value: string[] containing every choice value exactly once when non-empty (ODK odk:rank); partial rankings are a constraint failure. Renderer: drag with keyboard/menu alternative (WCAG 2.2 SC 2.5.7); flip icons in RTL.

{ "type": "rank", "name": "priorities", "label": "Rank your household priorities", "props": { "list": "needs" } }

10.7 rating

PropType · default
maxinteger 2–10 · 5
icon"star"|"number"|"smiley" · "star"
labels{ min?: LocalizedString; max?: LocalizedString }

Value: integer 1..max. > 10 is W_RATING_MAX_LARGE (use range or select_one). Renderer: radiogroup semantics, each option ≥ 44 px, never colour-only.

{ "type": "rating", "name": "satisfaction", "label": "How satisfied are you with the distribution?",
"props": { "max": 5, "icon": "smiley", "labels": { "min": "Very unsatisfied", "max": "Very satisfied" } } }

10.8 range

Props min (0), max (100), step (1), showValue (true). Value: number within [min, max] on the step grid. Renderer: slider fills from inline-start; a numeric input alternative MUST exist (sliders are hard with gloves).

{ "type": "range", "name": "fcs_share", "label": "Share of food from assistance (%)", "props": { "min": 0, "max": 100, "step": 5 } }

10.9 checkbox

No props. Value boolean (null when untouched; required: true means it must be true — use it for acknowledgements). Renderer: single checkbox with the label as the clickable text. XLSForm export maps to acknowledge (OK/empty).

{ "type": "checkbox", "name": "ack_reading", "label": "I have read the introduction to the respondent", "required": true }

10.10 matrix

PropType · defaultNotes
rowsArray<{ value: string; label: LocalizedString; relevant?: Expr }>≥ 1 (E_MATRIX_ROWS_EMPTY), values unique
columns{ type: "select_one"; list?; choices? } | { type: "number"; min?; max?; kind? } | { type: "text"; maxLength? }One column spec applied to every row: for select_one the choices are the visual columns (Likert grid)
requiredRows"all"|"any"|"none" · "none" when required false, "all" when true
{ "type": "matrix", "name": "access", "label": "Rate access to each service",
"props": { "rows": [ { "value": "water", "label": "Water" }, { "value": "health", "label": "Health" } ],
"columns": { "type": "select_one", "list": "agree_scale" } } }

Value { [rowValue]: value } where value is the column type's value; unanswered rows are absent. Renderer: table on wide screens, one card per row below 480 px; header row repeated on scroll; variant: "likert". Multi-question-per-row matrices (Kobo kobomatrix) are out of scope for v1 (open question).

10.11 geopoint / geotrace / geoshape

Propgeopointgeotrace/geoshape
accuracyThreshold (m) · 5auto-accept at or belowapplies per vertex
warningThreshold (m) · 100non-blocking warning aboveidem
autoCapture · falsestart capture on reveal
allowManual · trueplace on map / type coordinatestap-to-place
map · trueshow basemaprequired
mode"manual"|"auto" · "manual"; intervalSeconds · 10 for auto
minPoints2 (trace) / 3 unique (shape)
{ "type": "geopoint", "name": "site_loc", "label": "Site location", "required": true,
"props": { "accuracyThreshold": 10, "autoCapture": true, "map": false } }

Values: geopointGeo; geotraceGeo[] (≥ 2); geoshapeGeo[] closed ring (first == last, ≥ 4 entries) so area() and XForm export are unambiguous. lat ∈ [-90, 90], lng ∈ [-180, 180]; a mocked: true sidecar is written to submission.meta.ext["dev.rasd.geo"] when the platform reports a mock provider. Renderer: live accuracy readout, timeout after 60 s with fallback, offline PMTiles basemap when configured (research/09 §4.1).

PropType · defaultNotes
textLocalizedStringMUST (E_CONSENT_TEXT_MISSING); the full statement read/shown to the respondent
textVersionstringMUST; bump when text changes (builder warns W_CONSENT_TEXT_CHANGED_SAME_VERSION)
method"tap"|"signature"|"verbal" · "tap"signature embeds a signature pad; verbal records enumerator attestation
allowWithdrawboolean · falseShows a "withdraw consent" control on later pages
onWithdraw"clearSensitive"|"keep" · "clearSensitive"On withdrawal, values of bind.sensitive elements are cleared and audit event consent.withdrawn recorded
{ "type": "consent", "name": "consent", "label": "Informed consent",
"props": { "text": { "en": "We are collecting…", "ar": "نقوم بجمع…" }, "textVersion": "2026-06", "method": "tap", "allowWithdraw": true },
"required": true }

Value { granted: boolean, at: ISO, textVersion: string, locale: string, method: "tap"|"signature"|"verbal", signature?: AttachmentRef }. required means granted must be true to continue; a form that must proceed when consent is refused uses a trigger when: "${consent}.granted = false"complete. The element is always audited (§4.2) and is the anchor for 16 · Security consent reporting.

10.13 image · audio · video · file · signature

Propimageaudio / video / filesignature
source"camera"|"gallery"|"both" · "camera"
maxPixels · 1280long edge; proportional resize
quality · 0.7JPEG quality 0–1
annotate · falsedraw on photo
geotag · falsesidecar geo on the ref
multiple · false, maxCount · 5
maxBytes · 5 MiB (image) / 20 MiB (audio, file) / 25 MB (video)256 KiB
maxDurationSecondsaudio · 600 / video · 120
acceptMIME allow-list, e.g. ["application/pdf"]
penColorCSS colour · "#111"
{ "type": "image", "name": "site_photo", "label": "Photo of the distribution point",
"props": { "source": "camera", "maxPixels": 1280, "quality": 0.7, "geotag": true, "multiple": true, "maxCount": 3 } }
{ "type": "signature", "name": "resp_sig", "label": "Respondent signature", "required": true,
"props": { "penColor": "#1D4ED8" } }

Value AttachmentRef or AttachmentRef[] (multiple); signature is a trimmed PNG. Bytes go to StorageAdapter.attachments and are uploaded independently via tus. EXIF is stripped by default; the geotag is captured separately (research/09 §4.2). Renderer: show size estimate before recording; a per-submission attachment budget (default 10 MB, host-configurable — policy.submissionBudgetBytes, 14 §11, 09 §4.2, 02 · NFR-025) produces W_ATTACHMENT_BUDGET at finalize. Every size ceiling quoted above — the per-submission budget, the per-blob caps on web and native, and the video defaults — is normative only in 00 · Design spine §7.1; the values here restate that table and must be changed there first. In particular the 25 MB video.maxBytes default keeps a single recording inside the web blob cap, and the 120 s video.maxDurationSeconds default keeps duration and bytes mutually reachable at 720p.

10.14 barcode

Props formats: string[] (default ["qr_code","code_128","ean_13"]; vocabulary = BarcodeDetector names — the short spellings "qr" and "code128" used in 00 · Design spine §4.3 are accepted as aliases and normalised at load), allowManual (true), multiple (false, batch scanning appends). Value string (or string[] when multiple). Renderer: BarcodeDetectorbarcode-detector WASM ponyfill (self-hosted) on web; expo-camera on native.

{ "type": "barcode", "name": "ration_card", "label": "Scan ration card", "props": { "formats": ["qr_code"], "allowManual": true } }

10.15 note

Props style: "info"|"warning"|"success" (info), collapsible (false). The label is the note body (Markdown-safe subset, §11.4); hint is secondary text. Stores nothing (E_NOTE_WITH_VALUE_PROPS if required, constraint, default or calculate are set). Notes support {name} interpolation for read-back ("You said the household has {hh_size} members").

{ "type": "note", "name": "n_intro", "label": { "en": "**Read aloud:** This survey takes about 20 minutes." }, "props": { "style": "info" } }

10.16 hidden

No UI; default (typically { "expr": "${meta.deviceId}" } or a host-injected initialData value). Value: any JSON. hidden elements are never required (W_REQUIRED_HIDDEN).

{ "type": "hidden", "name": "enumerator", "default": { "expr": "${meta.username}" } }

10.17 calculate

calculate (base property) is MUST (E_CALCULATE_MISSING). Value: any JSON the expression returns (number, string, boolean, array, object). Not rendered; the builder shows it in the tree with a ƒ badge. Volatile functions (now(), random(), uuid()) inside calculate are re-evaluated on every dependency change; wrap in once() for stable ids.

{ "type": "calculate", "name": "hh_size", "calculate": "coalesce(${adults}, 0) + coalesce(${children}, 0)" }

10.18 group

Props nestData (false). appearance.variant: "section" (default) | "card" | "collapsible" | "field-list" (all children on one screen even in paged mode — groups do not paginate; that is what pages are for). elements[] at the element root. relevant hides the whole subtree and excludes all descendant values. Data: transparent — child values are stored at the parent level — unless nestData: true, in which case the group stores { child: value } under its own name (needed for 1:1 XForm nesting).

{ "type": "group", "name": "hh_head", "label": "Household head", "appearance": { "variant": "card" },
"elements": [ { "type": "text", "name": "head_name", "label": "Name", "bind": { "sensitive": true } },
{ "type": "number", "name": "head_age", "label": "Age", "props": { "kind": "integer", "min": 12, "max": 120 } } ] }

10.19 repeat

PropType · defaultNotes
min / maxinteger · 0 / 200min > maxE_REPEAT_MIN_GT_MAX; max > 500W_REPEAT_MAX_LARGE
countExprFixed instance count (ODK repeat_count); shrinking hides extra instances rather than deleting
addLabel / removeLabelLocalizedStringButton labels
itemLabelExprEvaluated per instance for the collapsed header, e.g. concat(${name}, ' (', ${age}, ')')
keyFieldchild nameDuplicate values across instances → constraint failure (W_REPEAT_KEY_DUPLICATE in drafts)
confirmDeleteboolean · true
allowReorderboolean · falseReorder UI (RNGH on native) with a non-drag alternative
{ "type": "repeat", "name": "hh_members", "label": "Household members",
"props": { "min": 1, "max": 30, "itemLabel": "concat(${m_name}, ' – ', ${m_age})", "keyField": "m_name", "addLabel": { "en": "Add member", "ar": "إضافة فرد" } },
"elements": [ { "type": "text", "name": "m_name", "label": "Name" }, { "type": "number", "name": "m_age", "label": "Age", "props": { "kind": "integer", "min": 0, "max": 120 } } ] }

Value object[]; each instance's keys are the child names (groups inside stay transparent). Nesting depth ≤ 3 repeats (E_REPEAT_NESTING). ${hh_members[].m_age} yields the array of ages for sum()/count(). Renderer: instances collapsed by default above 5, "Add" pinned at the end, delete requires confirmation, position() shown as "3 of 7".

10.20 x:<name> (custom)

Registered by the host with defineElement({ type: 'x:foo', component, builder?, valueSchema? }). props are host-defined and preserved untouched; valueSchema (a zod schema or Standard Schema) validates the stored value at finalize. Unregistered at render → placeholder card "Unsupported element x:foo" (never a crash), value preserved. All base properties (relevant, required, calculate, bind…) apply unchanged. type MUST match ^x:[a-z][a-z0-9-]*$ (E_CUSTOM_TYPE_NAME).

{ "type": "x:beneficiary-lookup", "name": "beneficiary", "label": "Find beneficiary",
"props": { "registry": "scope", "fields": ["id", "name"] }, "bind": { "sensitive": true } }

11. Localized strings, interpolation and media

11.1 Shape and resolution

A LocalizedString is a plain string (interpreted as defaultLocale) or { [bcp47]: string }. Resolution for the active locale: exact tag → language-only parent (ar-JOar) → settings.defaultLocale → any locale with a value → the element name in dev builds / empty in production. Fallback text is rendered with dir="auto" because its direction may differ from the form's (research/13 §6). ckb and ku never fall back to each other.

11.2 Which properties are localized

meta.title/description/changelog, pages[].title/description, label, hint, guidance, requiredMessage, constraintMessage, validators[].message, props.placeholder, props.unit, props.text (consent), props.addLabel/removeLabel, props.rows[].label, props.labels.min/max, props.other.label, Choice.label, Media.*, trigger message. Any property flagged x-rasd-localized in the JSON Schema.

11.3 Rasd Mini-Message

Localized strings MAY use the ICU subset fixed in 00 · Design spine §4.3a: {name} (value of a question, calculated value or {meta.userId}; select values render as their label; numbers per settings.numbering), {n, plural, =0 {…} one {…} two {…} few {…} many {…} other {…}} with #, {x, select, a {…} other {…}}, and ' escaping. Nothing else — no rich tags, no date skeletons. Inside a repeat, {name} resolves to the sibling of the current instance. XLSForm ${x} label templates are rewritten to {x} on import. Validation: E_MESSAGE_SYNTAX for unparsable strings; W_PLURAL_CATEGORY_MISSING when a locale lacks a CLDR category it requires (Arabic needs six); W_PLACEHOLDER_MISMATCH when a translation's placeholders differ from the default locale's.

11.4 Markdown-safe subset

label, hint, guidance, description, note bodies and consent text accept a Markdown subset: emphasis, links, ordered/unordered lists, line breaks, headings h3–h6, inline code, images from allowed media refs. Web renders through DOMPurify with the allow-list from research/11; native renders Markdown to native Text/Image and never HTML. Raw HTML in a string is stripped, not rendered.

11.5 Media references

Media.* values are relative asset paths (assets/food_ar.mp3, resolved by the host's resolveMedia(ref) and by the sync engine's form-attachment pull) or https:// URLs (allowed only when the origin is in the host mediaAllowList; W_MEDIA_REMOTE_URL at load). data:image/* URIs ≤ 32 KiB are allowed for icons. Per-language media are just LocalizedString maps.

11.6 String limits

label/hint/placeholder ≤ 4,000 chars; guidance/description ≤ 16,000; note body/consent text ≤ 32,000; any single string ≤ 64 KiB (E_STRING_TOO_LONG).


12. ext — custom payload

  • Where: any object node (root, meta, settings, Choice, ChoiceList, Dataset, Page, Element, appearance, bind, Trigger, Calculated).
  • Shape: { [vendorKey: string]: unknown }. Vendor keys SHOULD be reverse-DNS (org.wfp.moda) or an org slug (unrwa); values MUST be JSON (no functions, no undefined, no prototype keys). Rasd validates only "is a plain object" (E_EXT_NOT_OBJECT) and the prototype-key rule.
  • Reserved keys (Rasd tooling writes/reads them; hosts SHOULD NOT): org.getodk.xpath (verbatim XPath the importer could not map), org.getodk.xlsform (unmapped XLSForm rows/columns/settings for lossless export), org.kobotoolbox (Kobo score/rank/matrix/locking constructs), dev.rasd.builder (builder-only UI state: collapsed, colour, notes), dev.rasd.geo (device geo sidecars in submissions).
  • Round-trip guarantee: every ext value is preserved value-for-value through load, edit in the builder, save, diffDefinitions, storage, sync, and XLSForm/XForm export (as ext columns/attributes where the target allows, else re-attached on re-import). Key order is not preserved (canonical JSON sorts keys); the definition hash covers ext.
  • Size guidance: W_EXT_LARGE when a single node's ext exceeds 16 KiB or all ext exceeds 25 % of the document; the 2 MiB document cap still applies. Do not put datasets, base64 images or per-submission data in ext.
  • Builder: the inspector shows an "Extensions" section listing vendor keys as a read-only JSON tree; "Edit JSON" opens a validated raw editor; a plugin may register an inspector for its own key (plugins: [{ extKey: 'org.wfp.moda', inspector }]); unknown keys are never dropped on save; the diff view classes ext changes as C. At runtime useField(path).element.ext and custom renderers receive the full element.

13. Naming rules and reserved names

  • Element, calculated, choice-list and dataset names match ^[a-zA-Z_][a-zA-Z0-9_]*$, ≤ 64 chars (E_INVALID_NAME). Names are case-sensitive but MUST be unique case-insensitively (E_DUPLICATE_NAME, because SQL/CSV consumers are not).
  • Scope: element names and logic.calculated[].name share one namespace at form level; inside a repeat, names must be unique within that repeat and MUST NOT shadow an outer name (E_NAME_SHADOWED) — ${name} resolution would otherwise be ambiguous for readers of the data. Page ids, trigger ids, choice-list keys and dataset names each have their own namespace.
  • Reserved element names (E_RESERVED_NAME): meta, id, formId, formVersion, status, data, attachments, audit, ext, instanceID, deprecatedID, instanceName, formhub, start, end, today, deviceid, username, email, phonenumber, subscriberid, simserial, _id, _uuid, __version__, _submission_time, _index, _parent_index, _parent_table_name, KEY, PARENT_KEY. Anything starting with __ is reserved for Rasd. Names starting with a single _ are W_UNDERSCORE_NAME (they collide with Kobo/Ona export columns).
  • Implicit keys the engine creates: <name>_other for select_one/select_multiple with props.other.enabled — defining an element with that name is E_IMPLICIT_KEY_COLLISION.
  • Retired names: once a (id, version) is published, a removed element's name is reserved for that type forever; re-introducing it with a different type is blocked (§14).

14. Form versioning rules

14.1 Identity and hash

definitionHash = "sha256:" + hex(SHA-256(JCS(def without meta.updatedAt))) where JCS is RFC 8785 canonical JSON. Every stored definition, every submission (formVersion + definitionHash) and every server ack carries it. Publish is refused when the hash equals the previous version's (E_PUBLISH_UNCHANGED) or the version string is not greater than the latest published (E_VERSION_NOT_MONOTONIC) or was already used (E_VERSION_REUSED).

14.2 Change classes and validator behaviour

diffDefinitions(from, to) → { changes: SemanticChange[], plan: MigrationPlan, loss: "none"|"possible"|"certain" } classifies every change as C compatible, T compatible with a transform step, or B breaking (research/12 §3). Builder/validator policy when the previous version is published:

ChangeClassBuilderMigration hint (plan.steps[])
Add optional element; add choice; change any label/hint/media/translation; change appearance/theme hint; move element between pages/groups (non-repeat); reorder; add pageCallow
Add required elementTwarndefault, or drafts keep the new question unanswered (blocks finalize until answered)
Remove elementT (drafts)warn; name becomes retireddrop (keep: "orphan" default)
Rename element (name)T with alias, else Bprompt "Was X renamed to Y?" (detected from session patches)rename
Widen to text (select_onetext, any→text); widen props.kind int→decimal (type unchanged)Twarntransform
Any other type change (textnumber, datedatetime, select_multipleselect_one)Bblocknew name
Remove/rename choice valueTwarn; show impacted drafts if knownremapChoices (unmapped: "keep")
Change relevant/constraint/required/calculateC data / T behaviourallow; note in changelogrecalculate
Wrap scalar into repeat / unwrap repeat / move element into or out of a repeat / change repeat nameBblockmanual plan only
Remove a choice list or dataset still referencedBblock
Reuse a retired name with a different typeBblock
Change idBnew form
Change rasd minorCallow

Blocked changes can be forced by an admin (the builder's publish flow calls onPublish with force: true in the PublishRequest08 · Builder); the server records forced: true. Finalized and outbox submissions are never migrated; drafts are migrated only through migrateSubmission(sub, fromDef, toDef, plan) when plan.mode allows and loss is acceptable (09 · Offline storage). Servers accept any published version until purge.

flowchart TD
A["Edit on a published version"] --> B{"diffDefinitions class"}
B -->|C| P["Publish allowed"]
B -->|T| W["Warn and auto-seed plan step"] --> P
B -->|B| X{"force?"}
X -->|no| K["Publish blocked - fix, or use a new name or id"]
X -->|admin yes| P2["Publish with forced flag"]

15. Load-time validation catalogue

validateFormDefinition(def) → { ok, errors: Issue[], warnings: Issue[] }, Issue = { code, path: string /* JSON pointer */, message: string, hint?: string, ref?: string /* docs anchor */ }. createFormEngine() and <FormRenderer> throw/raise RasdError { code: "RASD_SCHEMA_INVALID", details: { issues } } when errors.length > 0; warnings are attached to the engine (engine.diagnostics) and logged once. Expression parse failures surfaced through parseExpression() use RasdError.code = "RASD_EXPR_PARSE"; inside definition validation they appear as E_EXPR_PARSE. Likewise, when createFormEngine() throws instead of returning issues, E_CALC_CYCLE surfaces as RasdError.code = "RASD_EXPR_CYCLE" and E_UNSUPPORTED_RASD_MAJOR / E_REQUIRES_UNMET as "RASD_UNSUPPORTED_SPEC" (17 · API reference). The catalogue is exhaustive for v1.0; new codes may be added in minors, never removed.

Errors (block loading)

CodeMessage template
E_JSON_INVALIDDocument is not valid JSON
E_PROTO_KEYForbidden key "{key}" at {path}
E_UNSUPPORTED_RASD_MAJORrasd {v} is not supported by this client (supports 1.x)
E_REQUIRES_UNMETForm requires {feature}, which this client lacks
E_SCHEMA{zod issue message} at {path} (structural pass; one per zod issue)
E_STRING_TOO_LONGString at {path} exceeds {limit} chars
E_DEFINITION_TOO_LARGEDefinition is {bytes} B; limit 2 MiB
E_INVALID_NAME / E_RESERVED_NAME / E_DUPLICATE_NAME / E_NAME_SHADOWED / E_IMPLICIT_KEY_COLLISIONName "{name}" …
E_UNKNOWN_TYPE / E_CUSTOM_TYPE_NAMEElement type "{type}" …
E_TYPE_PROPS_INVALIDprops.{key} invalid for {type}: {reason}
E_ELEMENTS_ON_LEAF"elements" is only allowed on group/repeat
E_NO_PAGES / E_PAGE_ID_DUPLICATE
E_LOCALE_INVALID / E_DEFAULT_LOCALE_NOT_IN_LOCALES
E_EXPR_PARSECannot parse expression at {path}: {detail} (with column)
E_EXPR_UNKNOWN_REFExpression references unknown field ${name}
E_EXPR_UNKNOWN_FUNCTIONUnknown function {fn}() (register it or add requires.features)
E_EXPR_TOO_LARGEExpression exceeds 4,000 chars / 5,000 AST nodes
E_CALC_CYCLECalculation cycle: {a} → {b} → {a}
E_CALCULATE_MISSING / E_DEFAULT_ON_CALCULATE / E_DEFAULT_TYPE_MISMATCH
E_SELECT_SOURCE / E_CHOICELIST_SOURCE / E_LIST_NOT_FOUND / E_CHOICE_DUPLICATE / E_INLINE_CHOICES_TOO_MANY
E_DATASET_NOT_FOUND / E_DATASET_COLUMN_UNKNOWN / E_DATASET_INLINE_TOO_MANY
E_MATRIX_ROWS_EMPTY / E_CONSENT_TEXT_MISSING / E_NOTE_WITH_VALUE_PROPS
E_REPEAT_MIN_GT_MAX / E_REPEAT_NESTING / E_NESTING_TOO_DEEP / E_TOO_MANY_ELEMENTS
E_TOO_MANY_VALIDATORS / E_MESSAGE_SYNTAX
E_TRIGGER_TARGET_UNKNOWN / E_TRIGGER_TARGET_CALCULATED / E_SKIPTO_UNKNOWN_PAGE / E_TRIGGER_LOOP
E_ENCRYPTION_KEY_MISSING / E_EXT_NOT_OBJECT
E_PUBLISH_UNCHANGED / E_VERSION_NOT_MONOTONIC / E_VERSION_REUSED / E_BREAKING_CHANGEpublish-time only (validateFormDefinition(def, { previous }))

Warnings (loaded; shown in builder and console)

CodeMeaning
W_UNKNOWN_PROPERTYUnknown property preserved (newer minor or typo)
W_NEWER_MINORDocument declares a newer 1.x than this client; degraded features possible
W_DEPRECATED_PROPERTYProperty marked deprecated in the schema
W_UNREGISTERED_CUSTOM_TYPEx: type not in the registry (render time)
W_LABEL_MISSING / W_MISSING_TRANSLATION / W_PLURAL_CATEGORY_MISSING / W_PLACEHOLDER_MISMATCH / W_LOCALE_DIR_MISMATCHi18n quality
W_INLINE_CHOICES_LARGE / W_FILTERKEYS_MISSING / W_UNUSED_CHOICE_LIST / W_UNUSED_DATASET / W_DATASET_COLUMN_UNKNOWN / W_DATASET_INLINE_LARGEchoices/datasets
W_REGEX_UNANCHORED / W_XPATH_UNMAPPED / W_CUSTOM_VALIDATOR_UNKNOWNlogic/import
W_REQUIRED_HIDDEN / W_REQUIRED_ON_GROUP / W_RATING_MAX_LARGE / W_REPEAT_MAX_LARGE / W_MANY_PAGES / W_EMPTY_PAGEstructure
W_SENSITIVE_WITHOUT_ENCRYPTION / W_INDEX_ON_SENSITIVE / W_SENSITIVE_IN_INSTANCE_NAME / W_MEDIA_REMOTE_URLdata protection
W_EXT_LARGE / W_UNDERSCORE_NAME / W_CONSENT_TEXT_CHANGED_SAME_VERSIONhygiene
W_VALUE_NOT_IN_LIST / W_REPEAT_KEY_DUPLICATE / W_ATTACHMENT_BUDGETruntime data warnings (engine, not load)

Two-pass order: structural pass runs first and short-circuits (a structurally invalid document is not linked); the semantic pass reports all issues it finds (no fail-fast) so the builder can show them together. Validation of a 2,000-element form MUST complete in < 200 ms on a 2019 mid-range Android phone (benchmark in @rasd/testing).


16. Size limits

LimitWarningErrorRationale
Definition bytes (UTF-8)512 KiB2 MiBLow-end Android parse/hash time; RSP payload
Elements (all, incl. containers)5002,000Builder virtualisation, engine graph size
Container depth (group/repeat nesting)46XLSForm export sanity, builder tree UX
Repeat nesting3ODK indexed-repeat limit; explicit indexing readability
Pages2001,000
Inline choices per list50010,000Use datasets above the warning
Inline choices total per form5,00050,000
Inline dataset rows2,00010,000Server datasets are unbounded (default row cap 100k on device)
Locales1020
String lengthsee §11.664 KiB
Expression source / AST1,000 chars4,000 chars / 5,000 nodesSandbox budget
Validators per element1020
Triggers / calculated200 / 500500 / 1,000
Repeat max500— (author-defined)Memory on low-end devices
ext per node / total16 KiB / 25 % of doc(document cap)

Hosts MAY tighten limits by passing overrides to validateFormDefinition(def, { limits }) (the builder exposes the same knob); the error thresholds cannot be loosened.


17. Compatibility and evolution policy

  • rasd = MAJOR.MINOR. MINOR additions: new optional properties, new element types, new REL functions, new enum values, new codes. MAJOR: anything that changes the meaning of an existing property, removes one, or changes a value shape.
  • Consumers on the same MAJOR MUST ignore-and-preserve unknown properties (W_UNKNOWN_PROPERTY), MUST render unknown element types as a placeholder and mark the engine degraded (unless requires says otherwise → refuse), and MUST refuse unknown functions (E_EXPR_UNKNOWN_FUNCTION) — silent wrong logic is worse than refusal.
  • A newer MAJOR is refused unless a bundled converter exists (convertDefinition(def, { to: "1" }), lossless via ext["dev.rasd.legacy"]).
  • Deprecation: mark with deprecated: true in the JSON Schema and .meta({ deprecated }) in zod; the validator emits W_DEPRECATED_PROPERTY; the property keeps working for the whole 1.x line and ≥ 12 months after the announcement; removal only in 2.0.
  • Schema publishing: https://schemas.rasd.dev/form/v1.json always resolves to the latest 1.x; frozen copies live at /form/v1.0.json, /form/v1.1.json, …; @rasd/core exports RFD_VERSION (the highest minor it writes) and SUPPORTED_RASD (["1.0", …]). The RSP server advertises supportedRasd and MAY strip minor-additive properties for older clients, never requires.
  • Element types and REL functions are the extension points that most often need a minor: they are gated per-feature through requires.features so an author can choose between "degrade gracefully" (omit) and "refuse on old clients" (declare).

18. Examples

18.1 Complete minimal form

{
"$schema": "https://schemas.rasd.dev/form/v1.json",
"rasd": "1.0",
"id": "site-visit-min",
"version": "1",
"meta": { "title": "Site visit" },
"settings": { "defaultLocale": "en", "locales": ["en"] },
"pages": [
{ "id": "main", "elements": [
{ "type": "text", "name": "site_name", "label": "Site name", "required": true },
{ "type": "select_one", "name": "status", "label": "Site status",
"props": { "choices": [ { "value": "open", "label": "Open" }, { "value": "closed", "label": "Closed" } ] } },
{ "type": "geopoint", "name": "location", "label": "Location" }
] }
]
}

Resulting submission.data: { "site_name": "Al Zaatari D3", "status": "open", "location": { "lat": 32.29, "lng": 36.32, "accuracy": 8, "capturedAt": "2026-08-15T07:41:03Z" } }.

18.2 Kitchen-sink excerpt (PDM)

{
"$schema": "https://schemas.rasd.dev/form/v1.json", "rasd": "1.0",
"id": "pdm-gfd-2026", "version": "3",
"requires": { "features": ["type:consent", "fn:pulldata"] },
"meta": { "title": { "en": "Post-Distribution Monitoring – GFD", "ar": "رصد ما بعد التوزيع" }, "tags": ["pdm", "gfd"],
"changelog": { "en": "v3: added coping strategies; retired old_note" }, "ext": { "org.wfp.moda": { "programme": "GFD-JO-2026" } } },
"settings": {
"defaultLocale": "en", "locales": ["en", "ar"], "navigation": "paged", "autosaveMs": 2000,
"instanceName": "concat(${hh_id}, ' – ', pulldata('geo_dist', 'name', 'code', ${district}))",
"submissionIdPrefix": "PDM",
"audit": { "enabled": true, "trackChanges": true, "location": { "enabled": true, "priority": "balanced", "minSeconds": 60, "minMeters": 50 } },
"encryption": { "mode": "field", "publicKeyId": "wfp-jo-2026" },
"theme": { "themeId": "rasd-field" },
"numbering": "latn", "localeMeta": { "ar": { "dir": "rtl", "numbering": "native" } }
},
"choiceLists": {
"yes_no": { "choices": [ { "value": "yes", "label": { "en": "Yes", "ar": "نعم" } }, { "value": "no", "label": { "en": "No", "ar": "لا" } } ] },
"governorate": { "source": { "type": "dataset", "dataset": "geo_gov" }, "valueKey": "code", "labelKey": "name" },
"district": { "source": { "type": "dataset", "dataset": "geo_dist" }, "valueKey": "code", "labelKey": "name", "filterKeys": ["gov_code"] },
"coping": { "choices": [ { "value": "sell_assets", "label": "Sold assets" }, { "value": "reduce_meals", "label": "Reduced meals" }, { "value": "none", "label": "None" } ] }
},
"datasets": [
{ "name": "geo_gov", "source": "server", "keyField": "code", "columns": [ { "name": "code" }, { "name": "name" } ] },
{ "name": "geo_dist", "source": "server", "keyField": "code", "columns": [ { "name": "code" }, { "name": "name" }, { "name": "gov_code" } ] }
],
"pages": [
{ "id": "consent", "title": { "en": "Consent", "ar": "الموافقة" }, "elements": [
{ "type": "note", "name": "n_intro", "label": { "en": "**Read aloud** the consent statement.", "ar": "**اقرأ** بيان الموافقة." } },
{ "type": "consent", "name": "consent", "label": "Informed consent",
"props": { "text": { "en": "…", "ar": "…" }, "textVersion": "2026-06", "method": "tap", "allowWithdraw": true } },
{ "type": "hidden", "name": "enumerator", "default": { "expr": "${meta.username}" } }
] },
{ "id": "hh", "title": "Household", "relevant": "${consent}.granted = true", "elements": [
{ "type": "text", "name": "hh_id", "label": "Household ID", "required": true, "props": { "mask": "AA-######" }, "bind": { "index": true, "sensitive": true } },
{ "type": "select_one", "name": "governorate", "label": "Governorate", "props": { "list": "governorate" }, "required": true },
{ "type": "select_one", "name": "district", "label": "District", "props": { "list": "district", "choiceFilter": "gov_code = ${governorate}", "search": true }, "required": true },
{ "type": "group", "name": "hh_size_g", "label": "Household size", "appearance": { "variant": "field-list" }, "elements": [
{ "type": "number", "name": "adults", "label": "Adults", "props": { "kind": "integer", "min": 0, "max": 30 }, "required": true },
{ "type": "number", "name": "children", "label": "Children", "props": { "kind": "integer", "min": 0, "max": 30 }, "required": true },
{ "type": "calculate", "name": "hh_size", "calculate": "${adults} + ${children}" }
] },
{ "type": "repeat", "name": "hh_members", "label": "Members", "props": { "min": 1, "count": "${hh_size}", "itemLabel": "concat('Member ', position())" }, "elements": [
{ "type": "number", "name": "m_age", "label": "Age", "props": { "kind": "integer", "min": 0, "max": 120 } },
{ "type": "select_one", "name": "m_sex", "label": "Sex", "props": { "choices": [ { "value": "f", "label": "Female" }, { "value": "m", "label": "Male" } ] }, "appearance": { "variant": "buttons" } }
] }
] },
{ "id": "food", "title": "Assistance", "elements": [
{ "type": "select_one", "name": "food_received", "label": { "en": "Did you receive food?", "ar": "هل استلمت الغذاء؟" }, "props": { "list": "yes_no" }, "required": true, "media": { "audio": { "ar": "assets/food_ar.mp3" } } },
{ "type": "date", "name": "dist_date", "label": "Distribution date", "relevant": "${food_received} = 'yes'",
"constraint": ". <= today()", "constraintMessage": "Cannot be in the future" },
{ "type": "select_multiple", "name": "coping", "label": "Coping strategies", "props": { "list": "coping", "exclusive": ["none"], "maxSelected": 3 } },
{ "type": "image", "name": "ration_photo", "label": "Photo of ration card", "props": { "source": "camera", "maxPixels": 1280, "geotag": true } },
{ "type": "rating", "name": "satisfaction", "label": "Satisfaction", "props": { "max": 5, "icon": "smiley" },
"validators": [ { "type": "expr", "expr": "not(${food_received} = 'yes' and . <= 2)", "message": "Low score — please add a comment", "severity": "warning" } ] },
{ "type": "x:beneficiary-lookup", "name": "beneficiary", "label": "Beneficiary record", "props": { "registry": "scope" }, "bind": { "sensitive": true },
"ext": { "org.wfp.moda": { "kpi": "PDM-07" } } }
] },
{ "id": "end", "title": { "en": "Wrap-up", "ar": "الختام" }, "elements": [
{ "type": "note", "name": "n_end", "label": { "en": "Thank the respondent and close the visit.", "ar": "اشكر المستجيب وأنهِ الزيارة." } }
] }
],
"logic": {
"calculated": [ { "name": "hh_size_total", "calculate": "sum(${hh_members[].m_age}) > 0 ? count(${hh_members}) : 0" } ],
"triggers": [
{ "id": "t-consent-no", "when": "${consent}.granted = false", "actions": [ { "type": "showMessage", "message": { "en": "Thank you for your time." } }, { "type": "complete" } ] },
{ "id": "t-no-food", "when": "${food_received} = 'no'", "actions": [ { "type": "clearValue", "target": "dist_date" }, { "type": "skipTo", "page": "end" } ] }
]
},
"ext": { "org.wfp.moda": { "formUid": "aXb12" } }
}

19. Concept mapping: RFD ↔ XLSForm/ODK ↔ SurveyJS

For migrators — XLSForm is the sector's de-facto interchange (research/01) and SurveyJS the largest web installed base; exact conversion rules are in 20 · Interoperability.

RFDXLSForm / ODK XFormsSurveyJS
id / versionsettings form_id / version— (external)
meta.titleform_titletitle
settings.defaultLocale, localesdefault_language, label::Lang (code) columnslocale, per-string { default, ar }
settings.navigation: "paged"style: pages (+ field-list groups)pages (always paged)
settings.instanceName / audit / encryptioninstance_name / audit meta type / public_key
pages[]begin_group … end_group with field-list (under style: pages)pages[]
group (transparent) / nestDatabegin_group (always nests)panel
repeat (count, min/max, itemLabel)begin_repeat (repeat_count)paneldynamic (panelCount, templateTitle)
text (format: email)text (+ constraint)text (inputType: email)
number kind: integer / decimalinteger / decimaltext inputType: number
select_one / select_multiple (list, choiceFilter, other)select_one L / select_multiple L (choice_filter, or_other)radiogroup/dropdown / checkbox (choicesVisibleIf, showOtherItem)
choiceLists (dataset source, filterKeys)choices sheet; select_one_from_file, select_one_externalchoices[], choicesByUrl
datasetsCSV/XML/GeoJSON secondary instances, entity listschoicesByUrl (live)
rankrank L (odk:rank)ranking
ratingselect_one + rating/likert appearance (Kobo begin_score)rating
rangerange (start/end/step)rating (numeric) or a custom slider widget
checkboxacknowledge (OK)boolean
consent— (note + acknowledge + start)
matrix (rows × single column spec)Kobo begin_kobomatrix (partial) / group of select_onematrix
date/time/datetimedate/time/dateTimetext inputType: date/time/datetime-local
geopoint/geotrace/geoshapesame
image/audio/video/filesame (+max-pixels, appearances)file
signatureimage + signature appearancesignaturepad
barcodebarcode
notenotehtml / expression (display)
hiddenhidden / calculate with defaulthidden question / calculatedValues
calculatecalculate (calculation)expression question
x:<name>ex: intent appearance (partial)custom question (ComponentCollection)
relevantrelevantvisibleIf
required (Expr) / requiredMessagerequired / required_messageisRequired / requiredIf, requiredErrorText
readonlyread_onlyenableIf (negated), readOnly
constraint / constraintMessageconstraint / constraint_messagevalidators: [{ type: "expression" }]
validators[] (regex, range, length, expr, custom)constraint expressionsvalidators[] (regex, numeric, text, expression)
default.value / default.exprdefault / calculation + once() or setvalue on first loaddefaultValue / defaultValueExpression
calculate (base property)calculationsetValueExpression
logic.calculated[]calculate rowscalculatedValues[] (includeIntoResult)
logic.triggers[] (setValue, clearValue, complete, skipTo, showMessage, custom)setvalue actions / trigger column (partial)triggers[] (setvalue, complete, skip, runexpression, copyvalue)
${name}, ${../x}, ${rep[2].f}${name}, ../x, indexed-repeat(){name}, {panel[1].f}
appearance.variantappearance columnrenderAs, colCount
extbind::*, body::*, unknown columns(top-level extra properties, silently kept)
Localized string { "en": …, "ar": … }label::English (en), label::Arabic (ar){ "default": …, "ar": … }

20. Acceptance criteria (spec + validator)

  • pnpm schema:build && pnpm schema:check is idempotent; the committed schema/rasd-form.schema.json matches the zod source; $id, $schema, deprecated, x-rasd-expr, x-rasd-localized are present.
  • Every property in this document exists in the zod schema with the same name, type and default; a generated "schema coverage" table (from .describe() metadata) is diffed against §2–§10 in CI.
  • docs/examples/*.form.json (including the minimal and kitchen-sink documents above) validate with zod and ajv, load into createFormEngine() with zero errors, and their submissions round-trip through toSubmission().
  • Every E_*/W_* code has at least one fixture that triggers it and a snapshot of its message and JSON-pointer path.
  • Unknown properties, unknown x: types and unknown ext keys survive load → save → hash → diff unchanged (property-based test with fast-check).
  • The prototype-pollution corpus (__proto__, constructor.prototype) is rejected at every depth; a 2 MiB + 1 byte definition is rejected before parsing completes.
  • Reserved-name, implicit-key (<name>_other), shadowing and case-insensitive duplicate rules are enforced with tests inside repeats and groups.
  • Publish-time validation with { previous } blocks the B rows of §14.2, warns on T rows, and detects rename vs remove+add from builder session patches.
  • Validation of the 2,000-element benchmark form completes in < 200 ms on the reference low-end Android device and < 50 ms on desktop Chrome.
  • rasd validate <file> (CLI) prints issues with codes, pointers and hints; exit code 1 on errors, 0 with warnings.
  • Every element type has a value-shape test, a default-type-mismatch test and an XLSForm round-trip test where a mapping exists; the JSON Schema validates against the 2020-12 meta-schema and loads in a Python jsonschema validator (CI job).

Security, accessibility and performance considerations

Cross-cutting requirements that this schema encodes; the mechanisms live in the linked documents.

  • A definition is data, never code. REL is statically parsed and evaluated in a sandbox without eval (spine §5); __proto__/constructor/prototype keys are rejected at every depth (E_PROTO_KEY) and the 2 MiB cap is enforced before full parsing (§16), per research/11. Markdown renders through a DOMPurify allow-list on web and native Text on RN — raw HTML is stripped, never rendered (§11.4). Remote media loads only from the host's mediaAllowList origins (§11.5).
  • Data-protection intent travels in the schema. bind.sensitive, settings.encryption, the consent element and the audit settings (§4.2–§4.3, §10.12) carry intent that 16 · Security implements; the validator's data-protection warnings (W_SENSITIVE_WITHOUT_ENCRYPTION, W_INDEX_ON_SENSITIVE, W_SENSITIVE_IN_INSTANCE_NAME) catch schema-level leaks before a form ships.
  • Accessibility is validated, not hoped for. Questions without labels raise W_LABEL_MISSING; every drag interaction (rank, repeat allowReorder) requires a keyboard/menu alternative (WCAG 2.2 SC 2.5.7); renderer notes per type (§10) fix touch-target minimums, radiogroup semantics and RTL behaviour, detailed in 06 · React / 07 · Native and 13 · i18n & a11y.
  • Performance is bounded by the limits in §16. They exist to keep parse, link and dependency-graph cost predictable on low-end Android: validation of the 2,000-element benchmark must finish in < 200 ms (§15); lists above 500 choices move to datasets, where filterKeys push filtering into indexed storage (§5); expression size is capped so the sandbox budget holds.

Open questions

  • Dataset-backed selects and label snapshots. research/12 recommends storing { value, label } for dataset-driven choices so exports stay reproducible when a dataset row is retired; the spine fixes select_one values as string. Proposal: keep string, and record { dataset, version, labels } provenance in submission.meta.ext["dev.rasd.datasets"] at finalize. Decide before v1.0 freeze.
  • Multi-column matrix. Kobo kobomatrix (several question columns per row) does not map to v1 matrix ({ [row]: value }). Candidate for 1.1 as props.columns[] with value { [row]: { [col]: value } }, or import as a group of per-row groups.
  • Form media manifest. Media refs (assets/…) are resolved by the host; there is no root-level manifest for the sync engine to prefetch. Proposal: optional root assets: [{ path, sha256, bytes, mime }] in 1.1, served alongside GET /v1/forms/{id}/versions/{version}.
  • time values without offset vs XForm's offset-carrying time. Current rule stores wall time; the XForm serializer appends the device offset. Confirm with the interop tests in 20 · Interoperability.
  • logic.calculated[].includeInData default is false here (SurveyJS habit); XLSForm authors expect calculates in data. Revisit after the importer lands.
  • Bare identifiers in choiceFilter. 05 · Logic ratifies the grammar (a bare identifier is legal only inside choiceFilter, where it names a column of the candidate choice); still open is whether the same rule should apply to matrix.rows[].relevant.
  • Widening lattice beyond →text. research/12 §3 recommends treating lossless widenings such as datedatetime as T (transform), Avro-style; the spine (§4.3b) blocks every type change except widening to text, so §14.2 classes them B. Propose relaxing the spine rule before 1.1.
  • Should relevant also accept a boolean literal (like required/readonly)? Harmless, but the spine keeps it Expr-only.