Skip to main content

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/core only.
  • 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 same StorageAdapter/SyncEngine contracts.
  • 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 RasdError values with stable RASD_* 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​

#DriverSourceArchitectural consequence
D1Offline for weeks on shared low-end Android (2 GB RAM, Android 7+)00 §2Storage-first writes; no network on the render path; runner ≤ 120 kB gz; virtualisation
D2Embedded in the host's app, not a platformresearch/05Library never owns auth, service worker, navigation or identity; everything is injectable
D3Same behaviour on web and native00 §3Headless engine + platform split by package
D4The form is data; logic sandboxed and portableresearch/09REL parser + interpreter, no eval; serialisable AST; server can re-implement
D5PII on devices in conflict settingsresearch/11Encryption at rest, crypto-shredding wipe, redacting logger, no telemetry
D6Definitions evolve while drafts and outbox items existresearch/12Immutable versions + definitionHash; drafts pinned; opt-in migration plans
D7Open-core subscription tokens, UN procurementresearch/06Offline Ed25519 verification; gating via features[]; export always works
D8Boring, supply-chain-safe toolingresearch/08pnpm 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&amp;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):

PackageResponsibility
@rasd/coreTypes, 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/builderDrag-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/syncOutbox sync engine and RSP client; transport adapters rsp (default), openrosa (phase 3), custom.
@rasd/pwaWorkbox helpers (registerRasdRoutes, precacheForms), SW update UX hooks, install prompt hook, background-sync bridge.
@rasd/mediaCapture adapters: geolocation, camera/photo, barcode/QR, signature, audio, file picker.
@rasd/licenseRLT verification (Ed25519 JWS), license state machine, refresh, offline grace, watermark policy.
@rasd/themesDefault theme JSON files, theme JSON Schema, createTheme(), DTCG interchange.
@rasd/xlsformXLSForm/ODK-XForm ⇄ RFD import/export and the XForm instance serializer used by the openrosa transport.
@rasd/serverReference RSP server and license service (Hono, Postgres, tus/S3).
@rasd/clirasd validate, rasd convert xlsform, rasd types, rasd theme check.
@rasd/testingrenderForm(), 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:

  1. @rasd/core imports nothing but zod. No react, DOM globals, react-native, fetch, or timers other than an injectable clock. It runs in Node, workers, browsers, Hermes.
  2. Adapters depend on core (or storage), never on renderers. sync does not know React; storage-* does not know sync.
  3. Renderers depend on core at runtime. They consume StorageAdapter, SyncEngine, license and media adapters as instances passed through <RasdProvider>; those types are import type-ed from @rasd/storage, @rasd/sync, @rasd/license, @rasd/media, declared as optional peer dependencies (erased by verbatimModuleSyntax, so no runtime edge). Without storage the renderer uses MemoryStorage.
  4. @rasd/builder and @rasd/element are leaves; nothing depends on them.
  5. @rasd/server is an island: wire types come from a generated @rasd/core/protocol entry 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​

TopologyStorageSync leaderService workerNotes
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 BroadcastChannelHost-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 Routersame adapter, created lazily inside a 'use client' component, never at module top levelsameSerwist 9.x (@serwist/next / @serwist/turbopack); next-pwa unsupportedPure @rasd/core modules may run in Server Components; renderer and builder via next/dynamic(..., { ssr: false })
Expo / React NativecreateSqliteStorage({ driver: 'expo' }) (op optional); WAL; SQLCipher via useSQLCipher; key in expo-secure-storeOne instance per process; AppState + netinfo triggers; expo-background-task opportunistic (≥ 15 min, OS-scheduled)n/aNew Architecture only (RN ≥ 0.81, Expo ≥ 54); Expo config plugin writes backup-exclusion rules
<rasd-form> elementDexie adapter inside the element (same-origin storage, shares host quota)Web Locks shared with other Rasd instances on the originHost's, if anyOpen 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)​

  1. Validate with zod (validateFormDefinition) → RASD_SCHEMA_INVALID with JSON-pointer paths; reject __proto__/constructor/prototype keys; enforce caps (definition ≤ 2 MiB, choice list ≤ 10 k, AST ≤ 5 k nodes) from research/11 §5.
  2. Flatten pages/groups/repeats into an element table keyed by path template (hh_members[*].age); groups are transparent for data unless props.nestData.
  3. 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.
  4. Extract static dependencies (${x}, ${../x}, ${/x}, ${meta.*}, pulldata datasets, volatile now()/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 is RASD_EXPR_CYCLE at load, surfaced by the builder.
  5. Freeze the compiled program (Object.freeze, lookup tables in Map/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; untouched FieldState objects 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 use select() with useSyncExternalStore.
  • Side effects live outside. The engine emits; storage, audit and sync are listeners. once(), default.expr and 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​

ExtensionAPINotes
Element typesdefineElement({ type: 'x:foo', component, builder?, valueSchema? }) → registry prop of RasdProvider/FormBuilderUnknown x: types render a placeholder and mark the form degraded; valueSchema (zod) validates at finalize
Component overridescomponents={{ TextField: MyTextField }}, renderers={{ select_one: MySelect }}Must honour the a11y contract (§7)
REL functionsregisterFunction(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 providerAsync validators set field.validating; 5 s timeout → warning
Sync transportstransport?: SyncTransport in createSyncEnginersp built-in; openrosa (phase 3) uses the @rasd/xlsform XForm serializer
Storage driversimplement StorageAdapterwa-sqlite/OPFS, nitro-sqlite adapters follow the same interface
ThemescreateTheme(partial, { extends }), fromDtcg()Tokens → CSS variables (web) / StyleSheet (native)
Mediamedia={{ camera, geolocation, … }} on the providerDefaults are lazy-loaded platform implementations
Builder pluginsplugins=[{ palette, inspector, logicActions }]Custom x: elements appear in the palette via builder.icon/label/inspector
ObservabilityonError, logger, onPolicyViolation, onAudit on the providerNo 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:

  1. 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 to null, produce a field-level warning with RASD_EXPR_RUNTIME in details, and one onError report per (element, code) per session — a hostile or buggy expression must never crash a form mid-interview.
  2. Storage. RASD_STORAGE_QUOTA is anticipated via estimate() (warn below 100 MB headroom) and caught on QuotaExceededError; the renderer keeps the draft in memory, shows a persistent banner and retries on the next change. RASD_STORAGE_MIGRATION failures roll back and refuse to open (data safety beats availability); the host receives onStorageBlocked({ reason, unsyncedCount }).
  3. Sync. Per-item outcomes never abort a batch; network errors retry with backoff (retryable: true); 4xx other than 408/425/429 move the item to rejected with reasons; RASD_SYNC_AUTH pauses sync and calls getAuthToken() again.
  4. 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.
  5. License. Never an exception in the render path; state changes are events (limited ⇒ watermark policy).
  6. 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​

ConcernMechanismBudget
Long formsnavigation: 'paged' renders one page; 'scroll' virtualises with @tanstack/react-virtual (web) / FlatList (native) above 60 visible elements; repeats virtualise beyond 20 instances500-question form: first paint < 1.5 s on a Moto G-class device; 60 fps scroll
Re-render fan-outPer-path subscriptions (§6.2), React.memo element wrappers, stable callbacks, no value-carrying contextOne keystroke re-renders ≤ 3 components
ExpressionsAST 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 / searchRows 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 pagination50 k admin units: < 100 ms per keystroke
BundleCode-split per capability: matrix, geo, signature, barcode (ZXing WASM), audio, locales, builder; size-limit in CIcore ≤ 45 kB, react ≤ 90 kB, runner ≤ 120 kB gz
Storage writesAutosave debounced (autosaveMs 2 000; flush on blur/page/visibility); batched patch with bumpRev; WAL + prepared statements; blobs never indexedAutosave ≤ 20 ms p95 for a 50 kB draft
AttachmentsCompress on capture (long edge 1 280 px); content-hash addressed; tus chunk 5 MiB; parallelism 2Photo ≤ 300 kB typical
StartupCompiled programs cached per definitionHash; definitions read lazily; license verification synchronousForm open ≤ 300 ms once data is local

12. Concurrency and data integrity​

  • Single-writer engine per submission. One FormEngine instance owns a submissionId per 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 Lock rasd-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, and patch rejects 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-Key per 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 checksum on every submission and attachment, echoed by the server; definitionHash on every stored definition; JWS-signed definitions optional.
  • Time. ISO-8601 UTC everywhere; the engine takes an injectable clock shared with tests and the license clock guard.

13. Versioning and compatibility​

AxisSchemeCompatibility rule
RFD spec (rasd: "1.0")MAJOR.MINORIgnore-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/* packagessemver; fixed group for runtime packages, independent for cli/xlsform/testingA minor may add RFD MINOR support; a patch never changes engine semantics; JSON Schema $id per spec version
Form id + versionstring, monotonic per id, immutable once published; definitionHashDrafts pinned; opt-in migrateSubmission; finalized never migrated; server accepts any published version
RSP/v1 prefix; additive fields only within v1Clients send X-Rasd-Client; server may advertise supportedRasd
RLTtyp: "RLT", kid rotationNew public keys ship in minor releases; JWKS fallback online
Storage schemainteger per adapter (Dexie.version(n), PRAGMA user_version)Ordered, checksum-tracked Migration[]; export works from any version
ThemerasdTheme: "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-store WHEN_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​

ServicePackageRuntimeNotes
RSP reference server@rasd/serverNode ≥ 20 container (Hono), Postgres 15+, S3-compatible bucket, tus handlerMulti-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/licenseNode ≥ 20, Postgres, Stripe webhooksPOST /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 proxyrecipe (Next route handler, Express, Django)hosttokenEndpoint forwarding to the license service with the org secret, or X-Rasd-License attached to RSP responses
Docs / playgroundapps/docs, apps/playground-webstaticDocusaurus 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​

ADRDecisionAlternativesRationale
ADR-01Headless core, renderers as adaptersRenderer-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-02REL (own Pratt parser + interpreter, no eval)ODK XPath engines (need DOM); Form.io JS strings; json-logic-js; jexl; jsonataXLSForm-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-03Dexie 4 for web storageraw IndexedDB/idb; RxDB; PouchDB; wa-sqlite/OPFS as defaultApache-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-04expo-sqlite default, op-sqlite optionalWatermelonDB; Realm; nitro-sqliteRealm sync is dead, Watermelon stale; expo-sqlite has SQLCipher, sessions, KV; op-sqlite adds FTS5/JSI (research/04 §2)
ADR-05Outbox + idempotency for submissions, not CRDTCRDT everywhere; PowerSync/Electric as coreSubmissions are single-writer, append-mostly; conflicts are retries, removed by idempotency; CRDT reserved for builder collaboration, LWW+HLC for records (research/04 §4)
ADR-06tus 1.0.0 for attachmentsmultipart in the JSON batch; S3 multipartResumable 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 dragpragmatic-drag-and-drop; react-dnd; hello-pangeaBest 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-08Ed25519 JWS (RLT) verified offline with embedded keysHMAC; RSA JWT; online checks; private registryToken can be public; ~5 kB @noble/ed25519 runs on RN; zero device calls; rotation via kid (research/06 §5)
ADR-09Open-core: Apache-2.0 core, FSL-1.1-Apache-2.0 gated packagesMIT + SaaS; AGPL + EE; EULA onlySource-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-10zustand + immer builder store, produceWithPatches undo/redoRedux Toolkit; Yjs day one; MobXTiny; patch history maps to semantic changelogs and version diffs; Yjs later behind the same store interface
ADR-11UUID v7 idsUUID v4; ULID; server idsTime-ordered for outbox FIFO and DB locality, offline-generated, RFC 9562; serializer maps to uuid: for OpenRosa
ADR-12Platform split by package, ESM-only, tsdown + bob.native.js extensions; dual CJS/ESMMetro skips extension expansion inside exports; dual packages duplicate React context (research/08 §5)
ADR-13Library never owns the service workerRasd-registered or CDN SWSW 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-14Element name is the storage keyPath-based keys (group/question)Group/page moves become non-breaking; Kobo/SurveyCTO lessons (research/12 §3)

17. Acceptance criteria (architecture level)​

  • @rasd/core test 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 --circular is clean.
  • size-limit passes: 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 → limited under a fake clock; clock rollback freezes state.
  • A throwing custom x: component is contained by its boundary; the page stays usable and onError receives one report.
  • storage.export() succeeds in limited license 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 the import type optional-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: FlatList only, 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?