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 (
$schema→https://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/coreare the executable source of truth;docs/schema/rasd-form.schema.jsonis 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 ofvalidateFormDefinition(). - Property names are camelCase; element
typevalues are snake_case (XLSForm-compatible where possible); custom types arex:<name>; every object node may carryext(vendor-namespaced, opaque, always round-tripped). - Element
nameis the storage key and is form-unique within its repeat scope; groups are transparent; repeats produceobject[]. 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). Noeval; 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 aRasdErrorwithcode: "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 bydefinitionHash.
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:buildrunsz.toJSONSchema(FormDefinitionSchema, { target: "draft-2020-12", io: "input", unrepresentable: "any" }), then a post-processor injects$id,$schema,title,descriptionfrom zod.describe()calls,deprecated: truefrom.meta({ deprecated: true }),x-rasd-expr: trueon every REL-typed property,x-rasd-localized: trueon localized strings, andadditionalProperties: trueon the extension points (ext,propsofx:*,appearance). Output is written with sorted keys and a trailing newline. - Check:
pnpm schema:check(CI) regenerates into a temp file anddiffs against the committed file; any difference fails the job.pnpm schema:testvalidates everydocs/examples/*.form.jsonand every fixture in@rasd/testingagainst 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 (
$schemaautocompletion), 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
| Thing | Convention | Example |
|---|---|---|
| Property names | camelCase | requiredMessage, choiceFilter |
Element type | snake_case, XLSForm-compatible where one exists | select_multiple, geopoint |
Custom element type | x: + kebab-case | x:beneficiary-lookup |
Element / list / dataset / calculated name | ^[a-zA-Z_][a-zA-Z0-9_]*$, ≤ 64 chars | hh_size |
Page id, trigger id | ^[a-zA-Z_][a-zA-Z0-9_-]*$, ≤ 64 chars | intro, t-consent-no |
Form id | ^[a-z0-9][a-z0-9_-]{0,63}$ (slug) | pdm-gfd-2026 |
| Enumerations | lowercase, kebab-case when multiword | "islamic-umalqura", "field-list" |
Vendor keys in ext | reverse-DNS or org slug | org.wfp.moda, unrwa |
2. Root document
| Property | Type | Required | Default | Semantics / validation |
|---|---|---|---|---|
$schema | string (URI) | SHOULD | — | https://schemas.rasd.dev/form/v1.json. Ignored by the runtime; used by editors. |
rasd | "MAJOR.MINOR" string | MUST | — | RFD spec version this document was written against, e.g. "1.0". Consumer rules in §17. |
id | slug | MUST | — | Stable form identifier, unique per organisation; changing it creates a new form (all history detaches). |
version | string | MUST | — | Monotonically 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[] } | MAY | — | Hard 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. |
meta | object | MUST | — | §3 |
settings | object | MUST | — | §4 |
choiceLists | { [name]: ChoiceList } | MAY | {} | §5 |
datasets | Dataset[] | MAY | [] | §6 |
pages | Page[] | MUST (≥ 1) | — | §7 |
logic | { calculated?: Calculated[]; triggers?: Trigger[] } | MAY | {} | §8 |
ext | Ext | MAY | — | §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
| Property | Type | Required | Notes |
|---|---|---|---|
title | LocalizedString | MUST | Shown in form lists and as the default page header. ≤ 200 chars per locale. |
description | LocalizedString | MAY | Markdown-safe subset (§11.4). |
tags | string[] | MAY | Free-form; ≤ 32 tags, each ≤ 40 chars. Used for filtering in the host's form list. |
author | string | MAY | Free text or email. |
createdAt / updatedAt | ISO-8601 UTC | MAY | Set by the builder. updatedAt is excluded from definitionHash (§14.1). |
changelog | LocalizedString | MAY | Human note for this version; the builder pre-fills it from diffDefinitions. |
ext | Ext | MAY |
4. settings
4.1 Core keys
| Property | Type | Default | Semantics |
|---|---|---|---|
defaultLocale | BCP 47 | MUST | Fallback locale for every LocalizedString. MUST be a member of locales (E_DEFAULT_LOCALE_NOT_IN_LOCALES). |
locales | string[] | 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. |
showProgress | boolean | true | Progress indicator (pages done / relevant pages). |
allowDrafts | boolean | true | false hides "Save draft"; autosave still writes a single recovery draft (never lose data, P5) which is discarded on finalize or explicit abandon. |
autosaveMs | integer | 2000 | Autosave debounce. 0 = write on every change; values 1–249 are raised to 250. |
instanceName | Expr | — | Evaluated 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). |
audit | object | see 4.2 | |
encryption | object | see 4.3 | |
theme | object | see 4.4 | |
ext | Ext | — |
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
| Property | Type | Semantics |
|---|---|---|
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). |
publicKeyId | string | Required 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.datasetMUST name an entry indatasets(E_DATASET_NOT_FOUND). - Inline lists:
W_INLINE_CHOICES_LARGEabove 500 choices,E_INLINE_CHOICES_TOO_MANYabove 10,000. Duplicatevalue→E_CHOICE_DUPLICATE. - Dataset lists: each row is a candidate;
valueKey/labelKeyMUST exist in the dataset's declaredcolumnswhen declared (E_DATASET_COLUMN_UNKNOWN), else a warning at first pull. Rows are filtered by the element'sprops.choiceFilter; conjunctions ofkey = ${x}wherekey ∈ filterKeysare pushed todatasets.query(name, { filter })(WHERE key = ?on SQLite, indexed on Dexie); the residual predicate is evaluated per row in JS. Lists withoutfilterKeysover > 5,000 rows areW_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 XLSFormchoice_filterstrings (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;
}
inlinerows:W_DATASET_INLINE_LARGEabove 2,000,E_DATASET_INLINE_TOO_MANYabove 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 columnnameof the row whosecodeequals the key; a missing row yieldsnull. Column names referenced bypulldataare checked againstcolumnswhen 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-triggered — when 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.
| Property | Type | Default | REL | Semantics / validation |
|---|---|---|---|---|
type | string | MUST | — | One 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). |
name | string | MUST | — | Storage key. Regex §1.4; unique in its repeat scope; reserved names §13. |
label | LocalizedString | MUST for questions and note; MAY for hidden, calculate, group, repeat | — | Missing on a question → W_LABEL_MISSING (accessibility). Mini-Message interpolation §11.3. |
hint | LocalizedString | — | — | Under the label, always visible. |
guidance | LocalizedString | — | — | Collapsible help (XLSForm guidance_hint). |
media | Media | — | — | Per-language image/audio/video shown with the label. Ref rules §11.5. |
required | boolean | Expr | false | ✓ | Enforced only when relevant. On containers: group → ignored (W_REQUIRED_ON_GROUP); repeat → at least props.min instances. |
requiredMessage | LocalizedString | renderer default | — | |
relevant | Expr | always relevant | ✓ | False ⇒ hidden, not validated, value excluded from finalized data (kept in the draft). |
readonly | boolean | Expr | false | ✓ | Displayed, 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). |
calculate | Expr | — | ✓ | Value is computed whenever a dependency changes; element becomes read-only. Cycles → E_CALC_CYCLE. |
constraint | Expr | — | ✓ | Evaluated only when the value is non-empty (ODK); . refers to the element's own value. Runs on change and on finalize. |
constraintMessage | LocalizedString | renderer default | — | |
validators | Validator[] | [] | ✓ (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 below | — | Storage/behaviour flags. |
props | object | {} | per type | Type-specific (§10). Unknown keys → W_UNKNOWN_PROPERTY; wrong types → E_TYPE_PROPS_INVALID. |
elements | Element[] | — | — | Only on group and repeat (E_ELEMENTS_ON_LEAF otherwise). |
ext | Ext | — | — | §12 |
validators[]:
type | Fields | Applies to |
|---|---|---|
regex | pattern (JS RegExp source, u flag, ≤ 500 chars, ≤ 5k steps guard), message | string values (text, barcode, select values) |
range | min?, max? (numbers or ISO strings) , message? | number, rating, range, date/time/datetime (lexicographic on ISO) |
length | min?, max?, message? | strings; arrays (item count) |
expr | expr (Expr, . = own value), message | any |
custom | id (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:
| Flag | Default | Effect |
|---|---|---|
sensitive | false | Value 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. |
saveIncomplete | true | Value 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). |
trackChanges | inherits settings.audit.trackChanges | Old/new values in audit events for this element. |
index | false | Storage 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
| Prop | Type · default | Notes |
|---|---|---|
multiline | boolean · false | textarea / 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) |
maxLength | integer · 4000 | Hard cap; counter shown above 80 % |
mask | string | # digit, A letter, * any, other chars literal (e.g. "###-####"); stored value is the raw input without mask literals |
placeholder | LocalizedString |
{ "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
| Prop | Type · default | Notes |
|---|---|---|
kind | "integer"|"decimal" · "decimal" | integer rejects fractions at input |
min / max | number | Built-in range check (error) |
step | number · 1 (integer) / any (decimal) | Stepper increment |
unit | LocalizedString | Suffix, e.g. { "en": "kg" } |
thousandsSeparator | boolean · false | Display 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
| Prop | Type · default | Notes |
|---|---|---|
min / max | ISO string literal | Static range check; use constraint (e.g. . <= today()) for dynamic bounds |
calendar | "gregorian"|"hijri" · settings.calendar | Display/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
| Prop | Type · default | Notes |
|---|---|---|
list | choiceLists key | XOR with choices (E_SELECT_SOURCE); MUST name a key in choiceLists (E_LIST_NOT_FOUND) |
choices | Choice[] | inline |
choiceFilter | Expr | Predicate over candidate choices (§5) |
search | boolean · auto (> 12 choices) | Searchable list; Arabic-normalised matching |
other | { enabled: boolean; label?: LocalizedString; value?: string } · disabled | Adds a free-text option. value defaults to "other"; the typed text is stored in the reserved sibling key <name>_other (XLSForm or_other convention) |
randomize | boolean · false | Shuffle 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
| Prop | Type · default |
|---|---|
max | integer 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
| Prop | Type · default | Notes |
|---|---|---|
rows | Array<{ 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
| Prop | geopoint | geotrace/geoshape |
|---|---|---|
accuracyThreshold (m) · 5 | auto-accept at or below | applies per vertex |
warningThreshold (m) · 100 | non-blocking warning above | idem |
autoCapture · false | start capture on reveal | — |
allowManual · true | place on map / type coordinates | tap-to-place |
map · true | show basemap | required |
mode | — | "manual"|"auto" · "manual"; intervalSeconds · 10 for auto |
minPoints | — | 2 (trace) / 3 unique (shape) |
{ "type": "geopoint", "name": "site_loc", "label": "Site location", "required": true,
"props": { "accuracyThreshold": 10, "autoCapture": true, "map": false } }
Values: geopoint → Geo; geotrace → Geo[] (≥ 2); geoshape → Geo[] 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).
10.12 consent
| Prop | Type · default | Notes |
|---|---|---|
text | LocalizedString | MUST (E_CONSENT_TEXT_MISSING); the full statement read/shown to the respondent |
textVersion | string | MUST; 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 |
allowWithdraw | boolean · false | Shows 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
| Prop | image | audio / video / file | signature |
|---|---|---|---|
source | "camera"|"gallery"|"both" · "camera" | — | — |
maxPixels · 1280 | long edge; proportional resize | — | — |
quality · 0.7 | JPEG quality 0–1 | — | — |
annotate · false | draw on photo | — | — |
geotag · false | sidecar geo on the ref | — | — |
multiple · false, maxCount · 5 | ✓ | ✓ | — |
maxBytes · 5 MiB (image) / 20 MiB (audio, file) / 25 MB (video) | ✓ | ✓ | 256 KiB |
maxDurationSeconds | — | audio · 600 / video · 120 | — |
accept | — | MIME allow-list, e.g. ["application/pdf"] | — |
penColor | — | — | CSS 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: BarcodeDetector → barcode-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
| Prop | Type · default | Notes |
|---|---|---|
min / max | integer · 0 / 200 | min > max → E_REPEAT_MIN_GT_MAX; max > 500 → W_REPEAT_MAX_LARGE |
count | Expr | Fixed instance count (ODK repeat_count); shrinking hides extra instances rather than deleting |
addLabel / removeLabel | LocalizedString | Button labels |
itemLabel | Expr | Evaluated per instance for the collapsed header, e.g. concat(${name}, ' (', ${age}, ')') |
keyField | child name | Duplicate values across instances → constraint failure (W_REPEAT_KEY_DUPLICATE in drafts) |
confirmDelete | boolean · true | |
allowReorder | boolean · false | Reorder 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-JO → ar) → 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, noundefined, 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
extvalue is preserved value-for-value through load, edit in the builder, save,diffDefinitions, storage, sync, and XLSForm/XForm export (asextcolumns/attributes where the target allows, else re-attached on re-import). Key order is not preserved (canonical JSON sorts keys); the definition hash coversext. - Size guidance:
W_EXT_LARGEwhen a single node'sextexceeds 16 KiB or allextexceeds 25 % of the document; the 2 MiB document cap still applies. Do not put datasets, base64 images or per-submission data inext. - 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 classesextchanges as C. At runtimeuseField(path).element.extand 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[].nameshare one namespace at form level; inside arepeat, 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_areW_UNDERSCORE_NAME(they collide with Kobo/Ona export columns). - Implicit keys the engine creates:
<name>_otherforselect_one/select_multiplewithprops.other.enabled— defining an element with that name isE_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:
| Change | Class | Builder | Migration 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 page | C | allow | — |
| Add required element | T | warn | default, or drafts keep the new question unanswered (blocks finalize until answered) |
| Remove element | T (drafts) | warn; name becomes retired | drop (keep: "orphan" default) |
Rename element (name) | T with alias, else B | prompt "Was X renamed to Y?" (detected from session patches) | rename |
Widen to text (select_one→text, any→text); widen props.kind int→decimal (type unchanged) | T | warn | transform |
Any other type change (text→number, date→datetime, select_multiple→select_one) | B | block | new name |
| Remove/rename choice value | T | warn; show impacted drafts if known | remapChoices (unmapped: "keep") |
Change relevant/constraint/required/calculate | C data / T behaviour | allow; note in changelog | recalculate |
Wrap scalar into repeat / unwrap repeat / move element into or out of a repeat / change repeat name | B | block | manual plan only |
| Remove a choice list or dataset still referenced | B | block | — |
Reuse a retired name with a different type | B | block | — |
Change id | B | new form | — |
Change rasd minor | C | allow | — |
Blocked changes can be forced by an admin (the builder's publish flow calls onPublish with force: true in the PublishRequest — 08 · 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)
| Code | Message template |
|---|---|
E_JSON_INVALID | Document is not valid JSON |
E_PROTO_KEY | Forbidden key "{key}" at {path} |
E_UNSUPPORTED_RASD_MAJOR | rasd {v} is not supported by this client (supports 1.x) |
E_REQUIRES_UNMET | Form requires {feature}, which this client lacks |
E_SCHEMA | {zod issue message} at {path} (structural pass; one per zod issue) |
E_STRING_TOO_LONG | String at {path} exceeds {limit} chars |
E_DEFINITION_TOO_LARGE | Definition is {bytes} B; limit 2 MiB |
E_INVALID_NAME / E_RESERVED_NAME / E_DUPLICATE_NAME / E_NAME_SHADOWED / E_IMPLICIT_KEY_COLLISION | Name "{name}" … |
E_UNKNOWN_TYPE / E_CUSTOM_TYPE_NAME | Element type "{type}" … |
E_TYPE_PROPS_INVALID | props.{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_PARSE | Cannot parse expression at {path}: {detail} (with column) |
E_EXPR_UNKNOWN_REF | Expression references unknown field ${name} |
E_EXPR_UNKNOWN_FUNCTION | Unknown function {fn}() (register it or add requires.features) |
E_EXPR_TOO_LARGE | Expression exceeds 4,000 chars / 5,000 AST nodes |
E_CALC_CYCLE | Calculation 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_CHANGE | publish-time only (validateFormDefinition(def, { previous })) |
Warnings (loaded; shown in builder and console)
| Code | Meaning |
|---|---|
W_UNKNOWN_PROPERTY | Unknown property preserved (newer minor or typo) |
W_NEWER_MINOR | Document declares a newer 1.x than this client; degraded features possible |
W_DEPRECATED_PROPERTY | Property marked deprecated in the schema |
W_UNREGISTERED_CUSTOM_TYPE | x: type not in the registry (render time) |
W_LABEL_MISSING / W_MISSING_TRANSLATION / W_PLURAL_CATEGORY_MISSING / W_PLACEHOLDER_MISMATCH / W_LOCALE_DIR_MISMATCH | i18n quality |
W_INLINE_CHOICES_LARGE / W_FILTERKEYS_MISSING / W_UNUSED_CHOICE_LIST / W_UNUSED_DATASET / W_DATASET_COLUMN_UNKNOWN / W_DATASET_INLINE_LARGE | choices/datasets |
W_REGEX_UNANCHORED / W_XPATH_UNMAPPED / W_CUSTOM_VALIDATOR_UNKNOWN | logic/import |
W_REQUIRED_HIDDEN / W_REQUIRED_ON_GROUP / W_RATING_MAX_LARGE / W_REPEAT_MAX_LARGE / W_MANY_PAGES / W_EMPTY_PAGE | structure |
W_SENSITIVE_WITHOUT_ENCRYPTION / W_INDEX_ON_SENSITIVE / W_SENSITIVE_IN_INSTANCE_NAME / W_MEDIA_REMOTE_URL | data protection |
W_EXT_LARGE / W_UNDERSCORE_NAME / W_CONSENT_TEXT_CHANGED_SAME_VERSION | hygiene |
W_VALUE_NOT_IN_LIST / W_REPEAT_KEY_DUPLICATE / W_ATTACHMENT_BUDGET | runtime 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
| Limit | Warning | Error | Rationale |
|---|---|---|---|
| Definition bytes (UTF-8) | 512 KiB | 2 MiB | Low-end Android parse/hash time; RSP payload |
| Elements (all, incl. containers) | 500 | 2,000 | Builder virtualisation, engine graph size |
| Container depth (group/repeat nesting) | 4 | 6 | XLSForm export sanity, builder tree UX |
| Repeat nesting | — | 3 | ODK indexed-repeat limit; explicit indexing readability |
| Pages | 200 | 1,000 | |
| Inline choices per list | 500 | 10,000 | Use datasets above the warning |
| Inline choices total per form | 5,000 | 50,000 | |
| Inline dataset rows | 2,000 | 10,000 | Server datasets are unbounded (default row cap 100k on device) |
| Locales | 10 | 20 | |
| String length | see §11.6 | 64 KiB | |
| Expression source / AST | 1,000 chars | 4,000 chars / 5,000 nodes | Sandbox budget |
| Validators per element | 10 | 20 | |
| Triggers / calculated | 200 / 500 | 500 / 1,000 | |
Repeat max | 500 | — (author-defined) | Memory on low-end devices |
ext per node / total | 16 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 enginedegraded(unlessrequiressays 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 viaext["dev.rasd.legacy"]). - Deprecation: mark with
deprecated: truein the JSON Schema and.meta({ deprecated })in zod; the validator emitsW_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.jsonalways resolves to the latest 1.x; frozen copies live at/form/v1.0.json,/form/v1.1.json, …;@rasd/coreexportsRFD_VERSION(the highest minor it writes) andSUPPORTED_RASD(["1.0", …]). The RSP server advertisessupportedRasdand MAY strip minor-additive properties for older clients, neverrequires. - Element types and REL functions are the extension points that most often need a minor: they are gated per-feature through
requires.featuresso 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.
| RFD | XLSForm / ODK XForms | SurveyJS |
|---|---|---|
id / version | settings form_id / version | — (external) |
meta.title | form_title | title |
settings.defaultLocale, locales | default_language, label::Lang (code) columns | locale, per-string { default, ar } |
settings.navigation: "paged" | style: pages (+ field-list groups) | pages (always paged) |
settings.instanceName / audit / encryption | instance_name / audit meta type / public_key | — |
pages[] | begin_group … end_group with field-list (under style: pages) | pages[] |
group (transparent) / nestData | begin_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 / decimal | integer / decimal | text 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_external | choices[], choicesByUrl |
datasets | CSV/XML/GeoJSON secondary instances, entity lists | choicesByUrl (live) |
rank | rank L (odk:rank) | ranking |
rating | select_one + rating/likert appearance (Kobo begin_score) | rating |
range | range (start/end/step) | rating (numeric) or a custom slider widget |
checkbox | acknowledge (OK) | boolean |
consent | — (note + acknowledge + start) | — |
matrix (rows × single column spec) | Kobo begin_kobomatrix (partial) / group of select_one | matrix |
date/time/datetime | date/time/dateTime | text inputType: date/time/datetime-local |
geopoint/geotrace/geoshape | same | — |
image/audio/video/file | same (+max-pixels, appearances) | file |
signature | image + signature appearance | signaturepad |
barcode | barcode | — |
note | note | html / expression (display) |
hidden | hidden / calculate with default | hidden question / calculatedValues |
calculate | calculate (calculation) | expression question |
x:<name> | ex: intent appearance (partial) | custom question (ComponentCollection) |
relevant | relevant | visibleIf |
required (Expr) / requiredMessage | required / required_message | isRequired / requiredIf, requiredErrorText |
readonly | read_only | enableIf (negated), readOnly |
constraint / constraintMessage | constraint / constraint_message | validators: [{ type: "expression" }] |
validators[] (regex, range, length, expr, custom) | constraint expressions | validators[] (regex, numeric, text, expression) |
default.value / default.expr | default / calculation + once() or setvalue on first load | defaultValue / defaultValueExpression |
calculate (base property) | calculation | setValueExpression |
logic.calculated[] | calculate rows | calculatedValues[] (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.variant | appearance column | renderAs, colCount |
ext | bind::*, 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:checkis idempotent; the committedschema/rasd-form.schema.jsonmatches the zod source;$id,$schema,deprecated,x-rasd-expr,x-rasd-localizedare 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 intocreateFormEngine()with zero errors, and their submissions round-trip throughtoSubmission(). - 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 unknownextkeys 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
jsonschemavalidator (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/prototypekeys 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 nativeTexton RN — raw HTML is stripped, never rendered (§11.4). Remote media loads only from the host'smediaAllowListorigins (§11.5). - Data-protection intent travels in the schema.
bind.sensitive,settings.encryption, theconsentelement 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, repeatallowReorder) requires a keyboard/menu alternative (WCAG 2.2 SC 2.5.7); renderer notes per type (§10) fix touch-target minimums,radiogroupsemantics 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
filterKeyspush 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 fixesselect_onevalues asstring. Proposal: keepstring, and record{ dataset, version, labels }provenance insubmission.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 v1matrix({ [row]: value }). Candidate for 1.1 asprops.columns[]with value{ [row]: { [col]: value } }, or import as agroupof 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 rootassets: [{ path, sha256, bytes, mime }]in 1.1, served alongsideGET /v1/forms/{id}/versions/{version}. timevalues without offset vs XForm's offset-carryingtime. Current rule stores wall time; the XForm serializer appends the device offset. Confirm with the interop tests in 20 · Interoperability.logic.calculated[].includeInDatadefault isfalsehere (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 insidechoiceFilter, where it names a column of the candidate choice); still open is whether the same rule should apply tomatrix.rows[].relevant. - Widening lattice beyond →
text. research/12 §3 recommends treating lossless widenings such asdate→datetimeas T (transform), Avro-style; the spine (§4.3b) blocks everytypechange except widening totext, so §14.2 classes them B. Propose relaxing the spine rule before 1.1. - Should
relevantalso accept a boolean literal (likerequired/readonly)? Harmless, but the spine keeps it Expr-only.
Related documents
- 00 · Design spine · 02 · Requirements · 03 · Architecture
- 05 · Logic & expressions — REL grammar, evaluation, dependency graph
- 06 · Renderer (React) · 07 · Renderer (Native) — how each element type is rendered
- 08 · Builder — inspector,
extpanel, publish flow and change detection - 09 · Offline storage · 10 · Sync protocol — definition/dataset storage,
definitionHash, migration plans - 12 · Theming —
settings.themeandschema/rasd-theme.schema.json - 13 · i18n, RTL & accessibility — LocalizedString resolution, Mini-Message, digits and calendars
- 14 · Media & field capture — attachment pipeline for image/audio/video/file/signature/geo
- 16 · Security & data protection —
bind.sensitive, encryption modes, consent - 17 · API reference —
validateFormDefinition,diffDefinitions,definitionHash,migrateSubmission - 20 · Interoperability — XLSForm/ODK/SurveyJS conversion rules
- schema/rasd-form.schema.json — machine-readable source of truth · examples — sample forms