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

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?