03 · Architecture
Purpose: Define the system and software architecture of Rasd Forms — the package graph, runtime topologies, engine design, adapter contracts, cross-cutting strategies and the architectural decisions behind them — so that every other design document and every package README can be checked against one model. Audience: Rasd core engineers; platform/mobile engineers at UN/NGO organisations who will embed, extend or self-host Rasd Forms.
TL;DR
- Rasd Forms is a headless engine (
@rasd/core, no UI, no platform APIs) surrounded by adapters: renderers (React, React Native), storage (Dexie, SQLite), sync transports (RSP, OpenRosa), media capture, license verification, themes. Dependencies point inward toward@rasd/coreonly. - Offline is the write path. Every change is stored first (autosave); sync is an outbox drained by a single-leader, foreground-driven engine; attachments use tus 1.0.0; production devices never talk to Rasd servers.
- The RFD is data; REL expressions are statically parsed into ASTs and a dependency DAG drives incremental, batched recomputation; renderers subscribe per field path.
- Four topologies — Vite/PWA, Next.js App Router, Expo/RN,
<rasd-form>custom element — share one engine and the sameStorageAdapter/SyncEnginecontracts. - Durability is architectural: single-writer engine per submission, atomic submission + outbox writes, UUID v7 ids, SHA-256 checksums, immutable published versions, plan-driven draft migration only.
- Errors are
RasdErrorvalues with stableRASD_*codes; UI failures are contained by error boundaries; the engine never throws during evaluation. - Optional services (Rasd Cloud license service, RSP reference server) are self-hostable Node ≥ 20 containers; the SDK works with zero Rasd infrastructure.
- Fourteen ADRs record the decisions (headless core, REL, Dexie/expo-sqlite, outbox-not-CRDT, dnd-kit, Ed25519 JWS, FSL open-core, zustand+immer, UUID v7, …).
1. Architectural drivers
| # | Driver | Source | Architectural consequence |
|---|---|---|---|
| D1 | Offline for weeks on shared low-end Android (2 GB RAM, Android 7+) | 00 §2 | Storage-first writes; no network on the render path; runner ≤ 120 kB gz; virtualisation |
| D2 | Embedded in the host's app, not a platform | research/05 | Library never owns auth, service worker, navigation or identity; everything is injectable |
| D3 | Same behaviour on web and native | 00 §3 | Headless engine + platform split by package |
| D4 | The form is data; logic sandboxed and portable | research/09 | REL parser + interpreter, no eval; serialisable AST; server can re-implement |
| D5 | PII on devices in conflict settings | research/11 | Encryption at rest, crypto-shredding wipe, redacting logger, no telemetry |
| D6 | Definitions evolve while drafts and outbox items exist | research/12 | Immutable versions + definitionHash; drafts pinned; opt-in migration plans |
| D7 | Open-core subscription tokens, UN procurement | research/06 | Offline Ed25519 verification; gating via features[]; export always works |
| D8 | Boring, supply-chain-safe tooling | research/08 | pnpm 11 + Turborepo, ESM-only, tsdown/bob, OIDC publishing, size-limit |
Quality attributes ranked: durability of field data > offline correctness > accessibility/RTL > performance on low-end devices > bundle size > developer ergonomics > feature breadth. When two are in tension, the higher one wins.
2. Context and container views
2.1 System context (C4 level 1)
flowchart LR
E["Enumerator / respondent"] -->|fills forms offline| HA[Host app<br/>web PWA or RN app<br/>embeds Rasd Forms]
D["Form designer / M&E officer"] -->|builds & publishes| HB[Host admin app<br/>embeds @rasd/builder]
HA -->|RSP v1: submissions, tus attachments,<br/>form & dataset pull| HS[Host backend<br/>@rasd/server or own RSP impl<br/>or ODK Central / Kobo via openrosa]
HB -->|publish RFD versions| HS
HS -.->|optional: proxy token refresh<br/>with org secret| RC[Rasd Cloud<br/>license service]
DEV[Developer / CI] -.->|trial & subscription tokens| RC
Two invariants are visible here: field devices only ever talk to the host's backend, and Rasd Cloud is reachable only from the host backend or from developer/CI contexts (see research/06 §3.7).
2.2 Containers (C4 level 2) inside a host app
flowchart TD
subgraph HOSTAPP["Host application process"]
UI[Host UI + routing + auth]
R["@rasd/react | @rasd/native<br/>FormRenderer, hooks, registry"]
B["@rasd/builder (lazy chunk)"]
C["@rasd/core<br/>engine · REL · validation · migration"]
S["@rasd/storage-dexie | @rasd/storage-sqlite"]
Y["@rasd/sync<br/>outbox engine + transport"]
LMT["@rasd/license · @rasd/media · @rasd/themes"]
end
SW["Service worker (host-owned)<br/>+ @rasd/pwa routes"]
DB[("IndexedDB / SQLite<br/>forms · submissions · attachments<br/>datasets · outbox · kv")]
API["Host backend (RSP)"]
UI --> R
UI --> B
B --> R
R --> C
R --> S
R --> LMT
Y --> S
Y --> API
S --> DB
SW -. cache defs/media .-> API
3. Package map, dependency rules and boundaries
The package list is normative in 00 §3; this section adds the rules. One-line responsibilities for orientation (the spine table is the source of truth):
| Package | Responsibility |
|---|---|
@rasd/core | Types, JSON Schemas, zod validators, the form engine (state, REL, validation, repeats, dependency graph), submission model, i18n primitives. No React, no DOM, no RN. |
@rasd/react / @rasd/native | <RasdProvider>, <FormRenderer>, hooks, field registry, default components; CSS-variable (web) / StyleSheet (native) theming. Same public API on both platforms. |
@rasd/builder | Drag-and-drop builder (palette · canvas · inspector · logic editor · translations · preview · JSON view · versions), undo/redo, custom-element plugins. |
@rasd/element | <rasd-form> custom element (open Shadow DOM) + self-hostable IIFE bundle for script-tag embeds. |
@rasd/storage (+ -dexie, -sqlite) | StorageAdapter interface, MemoryStorage, migrations framework, encryption helpers; Dexie 4 (IndexedDB) and expo-sqlite/op-sqlite drivers. |
@rasd/sync | Outbox sync engine and RSP client; transport adapters rsp (default), openrosa (phase 3), custom. |
@rasd/pwa | Workbox helpers (registerRasdRoutes, precacheForms), SW update UX hooks, install prompt hook, background-sync bridge. |
@rasd/media | Capture adapters: geolocation, camera/photo, barcode/QR, signature, audio, file picker. |
@rasd/license | RLT verification (Ed25519 JWS), license state machine, refresh, offline grace, watermark policy. |
@rasd/themes | Default theme JSON files, theme JSON Schema, createTheme(), DTCG interchange. |
@rasd/xlsform | XLSForm/ODK-XForm ⇄ RFD import/export and the XForm instance serializer used by the openrosa transport. |
@rasd/server | Reference RSP server and license service (Hono, Postgres, tus/S3). |
@rasd/cli | rasd validate, rasd convert xlsform, rasd types, rasd theme check. |
@rasd/testing | renderForm(), fake storage/clock/transport, network fault injection, form fixtures. |
3.1 Dependency graph
flowchart BT
core["@rasd/core (zod)"]
storage["@rasd/storage"] --> core
dexie["@rasd/storage-dexie (dexie)"] --> storage
sqlite["@rasd/storage-sqlite (expo-sqlite | op-sqlite)"] --> storage
sync["@rasd/sync"] --> storage
pwa["@rasd/pwa (workbox-*)"] --> sync
media["@rasd/media"] --> core
themes["@rasd/themes"] --> core
license["@rasd/license (@noble/ed25519)"]
react["@rasd/react (peer react)"] --> core
native["@rasd/native (peer react, react-native)"] --> core
builder["@rasd/builder (@dnd-kit/react, zustand, immer)"] --> react
element["@rasd/element"] --> react
xlsform["@rasd/xlsform (xlsx)"] --> core
cli["@rasd/cli"] --> core
cli --> xlsform
testing["@rasd/testing"] --> core
server["@rasd/server (Node ≥ 20)"]
Rules enforced by eslint-plugin-import-x no-restricted-paths and madge --circular in CI:
@rasd/coreimports nothing butzod. Noreact, DOM globals,react-native,fetch, or timers other than an injectableclock. It runs in Node, workers, browsers, Hermes.- Adapters depend on
core(orstorage), never on renderers.syncdoes not know React;storage-*does not knowsync. - Renderers depend on
coreat runtime. They consumeStorageAdapter,SyncEngine, license and media adapters as instances passed through<RasdProvider>; those types areimport type-ed from@rasd/storage,@rasd/sync,@rasd/license,@rasd/media, declared as optional peer dependencies (erased byverbatimModuleSyntax, so no runtime edge). Without storage the renderer usesMemoryStorage. @rasd/builderand@rasd/elementare leaves; nothing depends on them.@rasd/serveris an island: wire types come from a generated@rasd/core/protocolentry copied at build time, so the server never loads the engine.
3.2 Public vs internal surface
Public = names in 00 §11 plus per-package index.ts, guarded by an API-Extractor report (*.api.md) reviewed in PRs. Sub-path entries (@rasd/react/elements/matrix, @rasd/react/elements/geo, @rasd/media/barcode, @rasd/core/protocol, …) are stable, code-split units listed in exports. Internal = src/internal/**, no exports entry, may change in a patch. @rasd/*/unstable sub-paths carry no semver guarantee.
4. Runtime topologies
| Topology | Storage | Sync leader | Service worker | Notes |
|---|---|---|---|---|
| Browser PWA (Vite) | createDexieStorage — Dexie 4.4.x, one DB per namespace, navigator.storage.persist() at first sync (research/04) | Web Locks rasd-sync:<namespace>; other tabs get changes via BroadcastChannel | Host-owned Workbox 7.4.x / vite-plugin-pwa; registerRasdRoutes() adds rasd-defs-v1 (StaleWhileRevalidate, 200 entries) and rasd-media-v1 (CacheFirst, 30 d, 500 entries) | Library never calls skipWaiting/reload; host checks useRasdBusy() before updating (research/05 §4) |
| Next.js App Router | same adapter, created lazily inside a 'use client' component, never at module top level | same | Serwist 9.x (@serwist/next / @serwist/turbopack); next-pwa unsupported | Pure @rasd/core modules may run in Server Components; renderer and builder via next/dynamic(..., { ssr: false }) |
| Expo / React Native | createSqliteStorage({ driver: 'expo' }) (op optional); WAL; SQLCipher via useSQLCipher; key in expo-secure-store | One instance per process; AppState + netinfo triggers; expo-background-task opportunistic (≥ 15 min, OS-scheduled) | n/a | New Architecture only (RN ≥ 0.81, Expo ≥ 54); Expo config plugin writes backup-exclusion rules |
<rasd-form> element | Dexie adapter inside the element (same-origin storage, shares host quota) | Web Locks shared with other Rasd instances on the origin | Host's, if any | Open Shadow DOM, createRoot(shadowRoot), styles and popover portals inside the root; IIFE bundle inlines React; iframes are not a supported offline embed (partitioned storage, 10 % Safari quota) |
5. Key runtime sequences
5.1 Offline capture → autosave → finalize → outbox → sync → attachments
sequenceDiagram
autonumber
participant U as Enumerator
participant FR as FormRenderer
participant EN as FormEngine (core)
participant ST as StorageAdapter
participant SY as SyncEngine
participant API as Host RSP server
U->>FR: open form (offline)
FR->>ST: forms.get(id, version) · submissions.get(draftId)?
FR->>EN: createFormEngine(def, { initialData, meta })
U->>FR: answers
FR->>EN: setValue(path, v)
EN-->>FR: patch {changedPaths}
Note over FR,ST: autosave debounced autosaveMs (2 s), flushed on blur/page/visibilitychange
FR->>ST: submissions.patch(id, {data, audit, clientRev+1})
U->>FR: Finish
FR->>EN: validate() → finalize()
EN-->>FR: Submission {status:'finalized', checksum}
FR->>ST: transaction([submissions, outbox]): put(status 'queued') + outbox.enqueue
FR->>SY: syncNow('finalize')
Note over SY: leader lock, single-flight, backoff 1 s→5 min
SY->>ST: outbox.peek(50) · submissions status 'queued' → 'sending'
SY->>API: POST /v1/submissions:batch (Idempotency-Key, X-Rasd-Device)
API-->>SY: per item: accepted{serverRev} | duplicate | rejected | conflict
SY->>ST: status 'synced' | 'rejected' · outbox.ack(ids)
loop each pending attachment
SY->>API: HEAD /v1/attachments/{id} → Upload-Offset
SY->>API: PATCH chunks (tus 1.0.0, sha256 in Upload-Metadata)
SY->>ST: attachment status 'uploaded', remoteId
end
SY-->>FR: events progress · error · conflict
Design points: submission and outbox rows are written in one storage transaction (step 11) so a crash cannot leave a finalized submission without an outbox entry; a network failure during push returns sending items to queued for backoff retry (the status transitions are normative in 00 §6); the server marks a submission incomplete until every referenced attachment hash exists, so the two may arrive in any order; a rejected item leaves the outbox and returns to the enumerator with RASD_SYNC_REJECTED details for correction and re-finalization.
5.2 Publish → devices pull → draft migration
sequenceDiagram
autonumber
participant B as FormBuilder (host admin app)
participant HB as Host backend / @rasd/server
participant SY as SyncEngine (device)
participant ST as StorageAdapter
participant FR as FormRenderer
B->>B: validateFormDefinition(def) · diffDefinitions(prev, def) → changes, plan, loss
Note over B: breaking changes block publish unless forced · version must be > previous
B->>HB: onPublish(def, {changelog, plan}) → POST /v1/forms/{id}/versions
HB->>HB: reject duplicate version / unchanged definitionHash · store immutable · sign (JWS, optional)
HB-->>SY: SSE /v1/events formUpdated (or next GET /v1/forms?since=cursor)
SY->>HB: GET /v1/forms/{id}/versions/{version}
SY->>SY: verify sha256 == definitionHash · verify JWS if present · check rasd MAJOR supported
SY->>ST: forms.put(def) · kv.set('forms.cursor')
SY-->>FR: event formUpdated {id, version}
Note over FR: new submissions use latest version · drafts stay pinned to their version
FR->>FR: if drafts exist and plan.mode ≠ manual → offer migration
FR->>ST: migrateSubmission(draft, fromDef, toDef, plan) → put(draft{formVersion:new}) or keep old
Old definitions referenced by any draft or outbox item are retained on the device (plus the last 3 versions per form); finalized/queued submissions are never migrated and are accepted by the server against any published version (research/12 §5–6).
5.3 License bootstrap and refresh with offline grace
sequenceDiagram
autonumber
participant App as Host app start
participant LI as createLicense()
participant KV as storage.kv
participant HB as Host backend (tokenEndpoint or RSP X-Rasd-License)
App->>LI: createLicense({ token?, tokenEndpoint?, storage })
LI->>KV: get('license.token'), get('license.lastServerTime')
LI->>LI: verify Ed25519 JWS offline (embedded kid keys) · check apps[] vs origin/bundleId
alt device clock < lastServerTime
LI->>LI: freeze state (clock guard), rely on grace
end
LI-->>App: state$ emits evaluating|trial|active|grace|limited|invalid
Note over LI: refresh only if exp − now < 14 d, jittered, never blocking render
LI->>HB: GET tokenEndpoint (host adds org secret) — or — token in X-Rasd-License sync header
HB-->>LI: new RLT (exp = now + 60 d)
LI->>KV: set token, lastServerTime
LI-->>App: licenseRefreshed
State semantics (evaluating → trial → active → grace → limited, invalid) and the soft/hard enforcement policies are normative in 00 §9; the architectural point is that verification is a pure function of (token, embedded keys, clock, origin) and needs no I/O, so it can run in the render path without ever awaiting the network.
6. The headless engine
createFormEngine(def, opts) compiles an RFD once and then serves an immutable, versioned state snapshot.
6.1 Compilation (once per definition, cached by definitionHash)
- Validate with zod (
validateFormDefinition) →RASD_SCHEMA_INVALIDwith JSON-pointer paths; reject__proto__/constructor/prototypekeys; enforce caps (definition ≤ 2 MiB, choice list ≤ 10 k, AST ≤ 5 k nodes) from research/11 §5. - Flatten pages/groups/repeats into an element table keyed by path template (
hh_members[*].age); groups are transparent for data unlessprops.nestData. - Parse every REL string (
parseExpression, Pratt) into an AST; an LRU cache (2 000 entries) keyed by source string lets identical expressions share one AST. - Extract static dependencies (
${x},${../x},${/x},${meta.*},pulldatadatasets, volatilenow()/random()/uuid(),once()) and build one DAG over all bind properties (calculate,relevant,required,readonly,constraint,choiceFilter,count,default.expr,itemLabel,instanceName, triggers). Kahn sort; a cycle isRASD_EXPR_CYCLEat load, surfaced by the builder. - Freeze the compiled program (
Object.freeze, lookup tables inMap/Object.create(null)).
6.2 Runtime state and updates
interface FormEngine {
readonly definition: FormDefinition;
getState(): EngineState; // immutable snapshot, structurally shared
setValue(path: FieldPath, value: unknown, opts?: { source?: 'user' | 'calc' | 'migrate' }): void;
addRepeat(path: FieldPath, at?: number, initial?: Record<string, unknown>): void;
removeRepeat(path: FieldPath, index: number): void;
setLocale(locale: string): void;
validate(scope?: { page?: string; path?: FieldPath }): ValidationResult;
finalize(): Result<Submission, ValidationResult>; // all constraints/validators, strips irrelevant values, fires triggers
toSubmission(): Submission; // current draft, no validation
subscribe(listener: (patch: EnginePatch) => void): () => void; // whole-engine
subscribeField(path: FieldPath, listener: (f: FieldState) => void): () => void; // per-path
select<T>(selector: (s: EngineState) => T, equals?: (a: T, b: T) => boolean): { get(): T; subscribe(cb: () => void): () => void };
dispose(): void;
}
interface EnginePatch { rev: number; changedPaths: FieldPath[]; structural: boolean; } // structural = repeat add/remove, page relevance
Semantics:
- Immutable updates. Every mutation yields a new
EngineState(rev + 1) with structural sharing on the changed branch only; untouchedFieldStateobjects keep identity so React memoisation works. - Batching. Mutations within one macrotask coalesce; recomputation runs once per microtask over the dirty set in topological order, memoising unchanged results so downstream nodes stop early. Repeat add/remove re-binds wildcard dependencies before recompute.
- Subscription granularity.
subscribeField(path)fires only when that field's identity changes;subscribe()receives the coarse patch (autosave, audit, devtools). Renderers useselect()withuseSyncExternalStore. - Side effects live outside. The engine emits; storage, audit and sync are listeners.
once(),default.exprand volatile functions evaluate at instantiation only (ODK semantics). - Determinism. Same definition, meta, clock and inputs ⇒ identical state on web, native and server (property-tested in
@rasd/testing).
7. Renderer ↔ engine contract
The renderer is a thin projection of FieldState onto components. Both @rasd/react and @rasd/native implement the same contract:
interface FieldState {
path: FieldPath; name: string; type: ElementType;
value: unknown; relevant: boolean; required: boolean; readonly: boolean; calculated: boolean;
label: string; hint?: string; guidance?: string; // already localised for the active locale
choices?: Choice[]; // filtered by choiceFilter / dataset query
errors: FieldIssue[]; warnings: FieldIssue[]; infos: FieldIssue[];
touched: boolean; dirty: boolean; validating: boolean;
repeat?: { count: number; min?: number; max?: number; canAdd: boolean; canRemove: boolean };
a11y: { labelId: string; describedBy: string[]; invalid: boolean };
ext: Record<string, unknown>;
}
type ElementComponent<P = {}> = React.ComponentType<{ field: FieldState; engine: FormEngine; onChange(v: unknown): void; onBlur(): void } & P>;
Contract rules: (1) components read engine state only through field/hooks; (2) they call onChange (→ setValue) and onBlur (drives validateOn: 'blur'); (3) they render the accessible-props contract (labelId, describedBy, invalid); (4) irrelevant fields are unmounted, not CSS-hidden, so they never receive focus; (5) the readOnly renderer prop wins over field state; (6) validateOn ('change' default, 'blur', 'page', 'finalize') decides when the renderer calls validate() — the engine's constraint runs on change regardless. useField(path) = engine.select(s => s.fields.get(path)) + useSyncExternalStore; useRasdForm() exposes navigation (page, next(), prev(), canFinish), finalize() and busy.
8. Adapters and their interfaces
StorageAdapter is normative in 00 §7 and detailed in 09 · Offline storage. The remaining adapter contracts:
// @rasd/sync — transport adapter (rsp default; openrosa phase 3; custom)
interface SyncTransport {
readonly kind: 'rsp' | 'openrosa' | (string & {});
pullForms(cursor: string | null): Promise<{ items: FormManifest[]; cursor: string; nextEvents?: string }>;
fetchForm(id: string, version: string): Promise<{ definition: FormDefinition; jws?: string }>;
pullDataset(name: string, cursor: string | null): Promise<{ rows: DatasetRow[]; tombstones: string[]; cursor: string }>;
pushSubmissions(batch: Submission[], idempotencyKey: string): Promise<BatchResult[]>; // accepted|duplicate|rejected|conflict
attachments: { create(meta: AttachmentMeta): Promise<{ uploadUrl: string }>; offset(uploadUrl: string): Promise<number>; patch(uploadUrl: string, offset: number, chunk: Uint8Array | Blob, signal: AbortSignal): Promise<number> }; // tus 1.0.0
records?: { pull(form: string, cursor: string | null): Promise<RecordDelta>; push(ops: RecordOp[]): Promise<RecordResult[]> };
registerDevice(info: DeviceInfo): Promise<DevicePolicy>;
events?(onEvent: (e: ServerEvent) => void, signal: AbortSignal): void; // SSE
onResponseHeaders?(h: Headers): void; // X-Rasd-License, X-Rasd-Wipe
}
// createSyncEngine(...) returns { syncNow(reason?), pause(), resume(), status(), on(event, h) } per 00 §8
// @rasd/media — capability adapters (web + RN implementations behind one type)
interface GeolocationAdapter { watch(opts: { highAccuracy: boolean; timeoutMs: number }, cb: (fix: GeoFix) => void): () => void; }
interface CameraAdapter { capture(opts: { source: 'camera' | 'gallery' | 'both'; maxPixels?: number; quality?: number; geotag?: boolean }): Promise<CapturedFile | null>; }
interface BarcodeAdapter { readonly available: boolean; scan(opts: { formats: string[] }, signal: AbortSignal): Promise<string | null>; }
interface AudioAdapter { record(opts: { maxDurationSeconds: number }, signal: AbortSignal): Promise<CapturedFile | null>; }
interface FilePickerAdapter { pick(opts: { accept: string[]; maxBytes: number; multiple: boolean }): Promise<CapturedFile[]>; }
// signature pads are UI (ElementComponent) that produce a PNG blob
// @rasd/license — refresh transport is a plain function so hosts can proxy however they like
type TokenFetcher = (current: string | null) => Promise<{ token: string; serverTime?: string } | null>;
Conventions: create*() factories; every adapter has dispose(); all I/O accepts an AbortSignal; adapters throw RasdError (never raw driver errors) with cause preserved; @rasd/testing ships fakes (fakeStorage(), fakeTransport({ faults }), fakeClock()).
9. Extension points
| Extension | API | Notes |
|---|---|---|
| Element types | defineElement({ type: 'x:foo', component, builder?, valueSchema? }) → registry prop of RasdProvider/FormBuilder | Unknown x: types render a placeholder and mark the form degraded; valueSchema (zod) validates at finalize |
| Component overrides | components={{ TextField: MyTextField }}, renderers={{ select_one: MySelect }} | Must honour the a11y contract (§7) |
| REL functions | registerFunction(name, fn, { pure, arity? }) | Impure functions re-evaluate every recompute; pure ones memoise per argument tuple; register on the server too if used in instanceName |
| Validators | { type: 'custom', id } in RFD → validators={{ id: (value, ctx) => FieldIssue[] | Promise<…> }} on the provider | Async validators set field.validating; 5 s timeout → warning |
| Sync transports | transport?: SyncTransport in createSyncEngine | rsp built-in; openrosa (phase 3) uses the @rasd/xlsform XForm serializer |
| Storage drivers | implement StorageAdapter | wa-sqlite/OPFS, nitro-sqlite adapters follow the same interface |
| Themes | createTheme(partial, { extends }), fromDtcg() | Tokens → CSS variables (web) / StyleSheet (native) |
| Media | media={{ camera, geolocation, … }} on the provider | Defaults are lazy-loaded platform implementations |
| Builder plugins | plugins=[{ palette, inspector, logicActions }] | Custom x: elements appear in the palette via builder.icon/label/inspector |
| Observability | onError, logger, onPolicyViolation, onAudit on the provider | No built-in Sentry; createSentryScrubber() only |
10. Error-handling strategy
class RasdError extends Error {
code: RasdErrorCode; // 'RASD_SCHEMA_INVALID' | 'RASD_EXPR_PARSE' | 'RASD_EXPR_CYCLE' | 'RASD_EXPR_RUNTIME' | 'RASD_STORAGE_QUOTA' | 'RASD_STORAGE_LOCKED' | 'RASD_STORAGE_MIGRATION' | 'RASD_SYNC_REJECTED' | 'RASD_SYNC_NETWORK' | 'RASD_SYNC_AUTH' | 'RASD_SYNC_CONFLICT' | 'RASD_ATTACHMENT_FAILED' | 'RASD_LICENSE_EXPIRED' | 'RASD_LICENSE_INVALID' | 'RASD_UNSUPPORTED_SPEC' | 'RASD_POLICY_VIOLATION' | 'RASD_MEDIA_PERMISSION'
details?: Record<string, unknown>; // machine-readable: path, field, httpStatus, quota figures…
cause?: unknown; retryable: boolean; userMessage?: LocalizedString;
}
Layers:
- Engine never throws during evaluation. Parse/compile errors are thrown from
createFormEngine(fail fast, before UI). Runtime expression errors (division by null, bad date) evaluate tonull, produce a field-level warning withRASD_EXPR_RUNTIMEindetails, and oneonErrorreport per (element, code) per session — a hostile or buggy expression must never crash a form mid-interview. - Storage.
RASD_STORAGE_QUOTAis anticipated viaestimate()(warn below 100 MB headroom) and caught onQuotaExceededError; the renderer keeps the draft in memory, shows a persistent banner and retries on the next change.RASD_STORAGE_MIGRATIONfailures roll back and refuse to open (data safety beats availability); the host receivesonStorageBlocked({ reason, unsyncedCount }). - Sync. Per-item outcomes never abort a batch; network errors retry with backoff (
retryable: true); 4xx other than 408/425/429 move the item torejectedwith reasons;RASD_SYNC_AUTHpauses sync and callsgetAuthToken()again. - UI. Each element sits in an error boundary: a crashing custom component renders a fallback with "retry" and reports
onError; the rest of the page keeps working. A form-level boundary offers "save & reload" — the draft is already autosaved. - License. Never an exception in the render path; state changes are events (
limited⇒ watermark policy). - Recovery primitives.
storage.export()(JSONL + blobs) works in every state;syncEngine.status()exposes stuck items;rasd doctor(CLI/dev panel) prints storage estimate, persistence, migration state, license state and outbox age.
11. Performance architecture
| Concern | Mechanism | Budget |
|---|---|---|
| Long forms | navigation: 'paged' renders one page; 'scroll' virtualises with @tanstack/react-virtual (web) / FlatList (native) above 60 visible elements; repeats virtualise beyond 20 instances | 500-question form: first paint < 1.5 s on a Moto G-class device; 60 fps scroll |
| Re-render fan-out | Per-path subscriptions (§6.2), React.memo element wrappers, stable callbacks, no value-carrying context | One keystroke re-renders ≤ 3 components |
| Expressions | AST cache; DAG incremental recompute; pure-function memoisation; 10 ms step budget per batch (RASD_EXPR_RUNTIME warning if exceeded) | ≤ 2 ms per keystroke for 200-node DAGs |
| Datasets / search | Rows stay in storage; datasets.query uses filterKeys indexes; web search over > 10 k rows runs in a Web Worker (@rasd/storage-dexie/worker); native uses SQLite LIKE/FTS5 with pagination | 50 k admin units: < 100 ms per keystroke |
| Bundle | Code-split per capability: matrix, geo, signature, barcode (ZXing WASM), audio, locales, builder; size-limit in CI | core ≤ 45 kB, react ≤ 90 kB, runner ≤ 120 kB gz |
| Storage writes | Autosave debounced (autosaveMs 2 000; flush on blur/page/visibility); batched patch with bumpRev; WAL + prepared statements; blobs never indexed | Autosave ≤ 20 ms p95 for a 50 kB draft |
| Attachments | Compress on capture (long edge 1 280 px); content-hash addressed; tus chunk 5 MiB; parallelism 2 | Photo ≤ 300 kB typical |
| Startup | Compiled programs cached per definitionHash; definitions read lazily; license verification synchronous | Form open ≤ 300 ms once data is local |
12. Concurrency and data integrity
- Single-writer engine per submission. One
FormEngineinstance owns asubmissionIdper process;useSubmission(id)returns the shared instance (ref-counted, disposed 30 s after last unmount). A second tab opening the same submission is refused by Web Lockrasd-sub:<id>with an "open here?" hand-off event. - Write serialisation. Writes for one submission pass through a per-submission promise queue; each carries a monotonic
clientRev, andpatchrejects a stale one (RASD_STORAGE_LOCKED). - Transactions.
storage.transaction(['submissions','outbox'], …)for finalize;['forms','datasets','kv']for pull-apply; migrations run schema steps inside the engine's upgrade transaction and data steps chunked (500 rows) with a persisted cursor in_rasd_migrations(research/12 §9). - Sync leader. One syncer per origin/process; single-flight
syncNow()returns the in-progress promise; queues drain in priority order;Idempotency-Keyper batch makes replays safe. - Ordering. UUID v7 ids sort by creation time; outbox is FIFO per form; record edits carry an HLC.
- Integrity. SHA-256
checksumon every submission and attachment, echoed by the server;definitionHashon every stored definition; JWS-signed definitions optional. - Time. ISO-8601 UTC everywhere; the engine takes an injectable
clockshared with tests and the license clock guard.
13. Versioning and compatibility
| Axis | Scheme | Compatibility rule |
|---|---|---|
RFD spec (rasd: "1.0") | MAJOR.MINOR | Ignore-and-preserve unknown properties within a MAJOR; unknown element type ⇒ placeholder + degraded; unknown function ⇒ refuse (RASD_UNSUPPORTED_SPEC); MAJOR bumps ship a converter; deprecations ≥ 12 months |
@rasd/* packages | semver; fixed group for runtime packages, independent for cli/xlsform/testing | A minor may add RFD MINOR support; a patch never changes engine semantics; JSON Schema $id per spec version |
Form id + version | string, monotonic per id, immutable once published; definitionHash | Drafts pinned; opt-in migrateSubmission; finalized never migrated; server accepts any published version |
| RSP | /v1 prefix; additive fields only within v1 | Clients send X-Rasd-Client; server may advertise supportedRasd |
| RLT | typ: "RLT", kid rotation | New public keys ship in minor releases; JWKS fallback online |
| Storage schema | integer per adapter (Dexie.version(n), PRAGMA user_version) | Ordered, checksum-tracked Migration[]; export works from any version |
| Theme | rasdTheme: "1.0" | Any theme version renders any form version |
14. Security architecture (summary)
Full treatment in 16 · Security & data protection; the architectural commitments are:
- Trust boundaries: device ↔ host backend (TLS only;
http:refused outside localhost/dev); host backend ↔ Rasd Cloud (org secret, server-to-server); form author ↔ renderer (definitions are untrusted: DOMPurify allow-list on web, markdown-to-native on RN, media allow-list, prototype-pollution rejection, expression budgets). - At rest: AES-256-GCM with a non-extractable WebCrypto key (web); SQLCipher with the key in
expo-secure-storeWHEN_UNLOCKED_THIS_DEVICE_ONLY(native); index columns cleartext, payload and media encrypted; crypto-shredding is the wipe primitive; backup-exclusion via the Expo config plugin. - Secrets: host bearer tokens in memory (web) or SecureStore (RN); the RLT is public by design; tus URLs are capability secrets, redacted from logs.
- Least data: purge finalized submissions after ACK by default; datasets project only referenced columns; no telemetry; redacting logger;
createSentryScrubber(). - Verifiable posture:
securityReport()returns active controls for the host's CI and UN vendor packs (MASVS 2.1 / ASVS 5.0 mapping, SBOM, provenance).
15. Deployment of optional services
| Service | Package | Runtime | Notes |
|---|---|---|---|
| RSP reference server | @rasd/server | Node ≥ 20 container (Hono), Postgres 15+, S3-compatible bucket, tus handler | Multi-tenant by orgId; stores every published version + definitionHash; quarantines unknown versions/hashes instead of dropping; exports union-of-versions with __version/__definitionHash; docker compose + Helm for self-host; Rasd Cloud runs the same image |
| License service | @rasd/server/license | Node ≥ 20, Postgres, Stripe webhooks | POST /v1/trials, POST /v1/tokens/refresh, GET /.well-known/jwks.json, admin "PO paid" action; Rasd Cloud by default; air-gapped customers receive 12-month offline license files instead |
| Host proxy | recipe (Next route handler, Express, Django) | host | tokenEndpoint forwarding to the license service with the org secret, or X-Rasd-License attached to RSP responses |
| Docs / playground | apps/docs, apps/playground-web | static | Docusaurus 3.10 (en/ar/fr), Vite playground |
Procurement statement: beneficiary data never transits Rasd systems; Rasd Cloud sees org id, token metadata and SDK version only.
16. Architecture decision records
| ADR | Decision | Alternatives | Rationale |
|---|---|---|---|
| ADR-01 | Headless core, renderers as adapters | Renderer-integrated engine (SurveyJS style) | Identical logic on web and RN, engine testable without UI; SurveyJS/Form.io RN gaps come from DOM coupling (research/02 §9) |
| ADR-02 | REL (own Pratt parser + interpreter, no eval) | ODK XPath engines (need DOM); Form.io JS strings; json-logic-js; jexl; jsonata | XLSForm-familiar, static dependency extraction, CSP-safe, ~10 kB, server re-implementable; XPath engines don't run on RN; expr-eval has 2025–26 CVEs (research/09 §1) |
| ADR-03 | Dexie 4 for web storage | raw IndexedDB/idb; RxDB; PouchDB; wa-sqlite/OPFS as default | Apache-2.0, mature, blob-friendly; RxDB storages are paid; SQLite-WASM adds ~0.9 MB and 10× small-write latency — optional adapter only (research/04 §1) |
| ADR-04 | expo-sqlite default, op-sqlite optional | WatermelonDB; Realm; nitro-sqlite | Realm sync is dead, Watermelon stale; expo-sqlite has SQLCipher, sessions, KV; op-sqlite adds FTS5/JSI (research/04 §2) |
| ADR-05 | Outbox + idempotency for submissions, not CRDT | CRDT everywhere; PowerSync/Electric as core | Submissions are single-writer, append-mostly; conflicts are retries, removed by idempotency; CRDT reserved for builder collaboration, LWW+HLC for records (research/04 §4) |
| ADR-06 | tus 1.0.0 for attachments | multipart in the JSON batch; S3 multipart | Resumable across restarts, de-facto standard, IETF successor compatible; attachments decoupled from submissions |
| ADR-07 | @dnd-kit/react 0.5.x pinned behind dnd-adapter, action-menu for every drag | pragmatic-drag-and-drop; react-dnd; hello-pangea | Best touch/keyboard/a11y story; 0.x risk contained by the adapter; WCAG 2.2 SC 2.5.7 via non-drag path (research/03) |
| ADR-08 | Ed25519 JWS (RLT) verified offline with embedded keys | HMAC; RSA JWT; online checks; private registry | Token can be public; ~5 kB @noble/ed25519 runs on RN; zero device calls; rotation via kid (research/06 §5) |
| ADR-09 | Open-core: Apache-2.0 core, FSL-1.1-Apache-2.0 gated packages | MIT + SaaS; AGPL + EE; EULA only | Source-available builds trust with UN reviewers, converts to Apache-2.0 after 2 years; gating is features[] config so Option B stays a flag flip (00 §9) |
| ADR-10 | zustand + immer builder store, produceWithPatches undo/redo | Redux Toolkit; Yjs day one; MobX | Tiny; patch history maps to semantic changelogs and version diffs; Yjs later behind the same store interface |
| ADR-11 | UUID v7 ids | UUID v4; ULID; server ids | Time-ordered for outbox FIFO and DB locality, offline-generated, RFC 9562; serializer maps to uuid: for OpenRosa |
| ADR-12 | Platform split by package, ESM-only, tsdown + bob | .native.js extensions; dual CJS/ESM | Metro skips extension expansion inside exports; dual packages duplicate React context (research/08 §5) |
| ADR-13 | Library never owns the service worker | Rasd-registered or CDN SW | SW must be same-origin; reload control belongs to the host; Background Sync is Chromium-only so the outbox is app-level (research/05 §10) |
| ADR-14 | Element name is the storage key | Path-based keys (group/question) | Group/page moves become non-breaking; Kobo/SurveyCTO lessons (research/12 §3) |
17. Acceptance criteria (architecture level)
-
@rasd/coretest suite passes under Node with no DOM globals and under Hermes (Expo example) with identical snapshots. - Dependency-rule lint fails on any forbidden import edge (§3.1);
madge --circularis clean. -
size-limitpasses: core ≤ 45 kB, react ≤ 90 kB, runner ≤ 120 kB gz; builder is a separate chunk. - Killing the app between autosave and finalize, or between finalize and first sync, loses no data (Playwright offline + Maestro).
- Two open tabs: exactly one syncs; both see status updates via
BroadcastChannel. - Sequence 5.1 completes on a throttled 3G profile with a 5 MB photo, surviving one forced restart mid-upload (tus resume).
- Publishing v2 while a v1 draft exists keeps the draft on v1; migration only after opt-in and only for drafts.
- License verification with the network disabled yields
active → grace → limitedunder a fake clock; clock rollback freezes state. - A throwing custom
x:component is contained by its boundary; the page stays usable andonErrorreceives one report. -
storage.export()succeeds inlimitedlicense state and on a storage version behind the current migration.
Open questions
- Should minimal port interfaces for storage/sync/license live in
@rasd/core(no type-only peers for renderers), or is theimport typeoptional-peer approach acceptable long term? - The publish endpoint (
POST /v1/forms/{id}/versions) is not in the spine's RSP table — is publish part of RSP v1 or a separate admin protocol of@rasd/server? - Web Worker offloading for dataset search: ship in v1 or defer until measurements on 2 GB Android devices show the need?
- Expose a signals-style primitive publicly (for non-React hosts) or keep
select()/subscribe()as the only contract? - Native virtualisation:
FlatListonly, or an optional FlashList adapter for repeats with > 100 instances? - Ship a Yjs-backed builder store interface in v1 (unused) to guarantee the ADR-10 swap path, or accept a later refactor?
Related documents
- 00 · Decisions & conventions
- 01 · Vision & scope · 02 · Requirements
- 04 · Form schema spec · 05 · Logic & expressions
- 06 · Renderer (React) · 07 · Renderer (native) · 08 · Builder
- 09 · Offline storage · 10 · Sync protocol · 11 · PWA & embedding
- 12 · Theming · 13 · i18n, RTL & accessibility · 14 · Media & field capture
- 15 · Licensing & billing · 16 · Security & data protection
- 17 · API reference · 18 · Engineering practices · 19 · Roadmap & work breakdown · 20 · Interoperability · 21 · Getting started
- RFD JSON Schema · Theme JSON Schema