00 · Design Spine — Decisions & Conventions (normative)
Status: Normative. Every other document in
docs/MUST agree with this file. If a document and this file conflict, this file wins and the other document is a bug. Working name: Rasd Forms (رصد = "monitoring"). npm scope@rasd/*. The name is a placeholder that can be renamed by a single search-and-replace; nothing in the design depends on it.
1. One-paragraph definition
Rasd Forms is an open-core, offline-first, dynamic forms & survey toolkit for React (web / PWA) and React Native, aimed at humanitarian and UN-style field monitoring (distribution monitoring, post-distribution monitoring, site visits, third-party monitoring, assessments, beneficiary feedback). Developers install it into their own website or app. It ships (1) a headless form engine driven by a JSON form definition ("RFD"), (2) renderers for React and React Native, (3) a drag-and-drop builder, (4) storage adapters (IndexedDB on web, SQLite on native) with an outbox-based sync engine and a published server contract, (5) PWA helpers, (6) a theme system (design-token JSON → CSS variables on web, StyleSheet on native), (7) field-capture adapters (GPS, photo, barcode, signature, audio, files), and (8) a license SDK that gives a free 7-day trial and then requires a monthly-subscription token.
2. Non-negotiable product principles
| # | Principle | Consequence |
|---|---|---|
| P1 | Offline is the default, not a feature. | Every read/write goes through local storage first. Network is opportunistic. Nothing in the renderer awaits the network. |
| P2 | The form is data. | The whole form (layout, logic, translations, theme hints, custom payload) is one JSON document that can be stored, versioned, diffed, imported/exported and validated against a JSON Schema. |
| P3 | Headless core, pluggable everything. | @rasd/core has zero UI and zero platform APIs. Renderers, storage, sync transport, media capture, license transport and themes are all adapters behind interfaces. |
| P4 | Custom payload is first-class. | Every schema node has an ext object (namespaced by vendor key) that Rasd never touches and always round-trips. Custom element types (x:*) are registered by the host app. |
| P5 | Field-monitor ergonomics. | Big touch targets, one-hand use, Arabic RTL, low-end Android, autosave every change, resumable everything, never lose data. |
| P6 | Data protection by design. | PII flags, encryption at rest, no telemetry by default, exportable data even when the license has expired. |
| P7 | Boring, standard tech. | TypeScript strict, pnpm monorepo, well-known libraries (dnd-kit, Dexie, expo-sqlite/op-sqlite, Workbox, zod). No custom crypto. |
3. Package map (pnpm workspace, packages/*)
| Package | Runtime | Purpose | Depends on |
|---|---|---|---|
@rasd/core | any JS | Types, JSON Schemas, zod validators, form engine (state, expressions, validation, repeats, dependency graph), submission model, i18n primitives, XLSForm-compatible semantics. No React, no DOM, no RN. | zod |
@rasd/react | browser (also react-native-web) | <RasdProvider>, <FormRenderer>, hooks (useRasdForm, useField, useSubmission), field registry, default web components, CSS-variable theming. | @rasd/core, peer react |
@rasd/native | React Native / Expo | Same public API as @rasd/react (FormRenderer, hooks, registry) with RN components and StyleSheet theming. Includes the RasdSecurity Expo Module (screenshot blocking, backup exclusion, root/jailbreak signal) — there is no separate @rasd/native-security package. | @rasd/core, peer react, react-native |
@rasd/builder | browser | Drag-and-drop form builder (palette · canvas · inspector · logic editor · translations · preview · JSON view · versions), undo/redo, custom-element plugins. DnD via @dnd-kit/react 0.5.x (pinned) behind an internal dnd-adapter so the library can be swapped; every drag has a non-drag equivalent (⋯ action menu: move up/down/into…) to satisfy WCAG 2.2 SC 2.5.7. | @rasd/react, @dnd-kit/react, zustand, immer |
@rasd/element | browser | Framework-agnostic embed: <rasd-form> custom element (open Shadow DOM, CSS-variable theming, DOM events) + a self-hostable IIFE bundle rasd-forms.iife.js (bundles React) for script-tag use. | @rasd/react |
@rasd/storage | any JS | StorageAdapter interface, MemoryStorage, migrations framework, encryption helpers (WebCrypto / RN crypto via adapter). | @rasd/core |
@rasd/storage-dexie | browser | IndexedDB implementation via Dexie 4 (+ blob store for attachments, navigator.storage.persist()). | @rasd/storage, dexie |
@rasd/storage-sqlite | React Native | SQLite implementation (drivers: expo-sqlite default, op-sqlite optional; SQLCipher when available). | @rasd/storage |
@rasd/sync | any JS | Outbox sync engine (submissions, attachments, form-definition pull, dataset pull), backoff, connectivity, conflict surfacing, Rasd Sync Protocol client. Transport is an adapter: rsp (default), openrosa (ODK Central / KoboToolbox; phase 3), custom. | @rasd/storage |
@rasd/pwa | browser | Workbox helpers (registerRasdRoutes, precacheForms), SW update UX hooks, install prompt hook, background-sync bridge. | @rasd/sync, workbox-* |
@rasd/media | web + RN (platform files) | Capture adapters: geolocation, camera/photo (+compression, EXIF), barcode/QR, signature pad, audio, file picker. | @rasd/core |
@rasd/license | any JS | Trial + subscription license token verification (Ed25519 JWS), state machine, refresh, offline grace, watermark policy. | @noble/ed25519 |
@rasd/themes | any JS | Default theme JSON files (rasd-light, rasd-dark, rasd-high-contrast, rasd-field = big-touch outdoor), theme JSON Schema, createTheme(). | @rasd/core |
@rasd/xlsform | any JS | XLSForm/ODK-XForm ⇄ RFD import/export (best-effort mapping, XPath → REL, Kobo extensions), plus the XForm instance (XML) serializer/parser used by the openrosa sync transport to talk to Kobo / ODK Central / Ona. Node-only helpers may shell out to pyxform in CI. | @rasd/core, xlsx |
@rasd/server | Node ≥ 20 | Reference implementation of the Rasd Sync Protocol (Hono handlers, Postgres adapter, S3-compatible attachments via tus) and the license service reference. Customers may self-host or use Rasd Cloud. | — |
@rasd/cli | Node | rasd validate, rasd convert xlsform, rasd types (generate TS types from a form), rasd theme check, rasd doctor (environment/offline-readiness check), rasd license {trial|check|refresh}. | @rasd/core, @rasd/xlsform |
@rasd/testing | any JS | Test utilities: renderForm(), fake storage, fake clock, network fault injection, form fixtures. | @rasd/core |
apps/*: docs (Docusaurus), playground-web (Vite), example-next (Next.js App Router), example-expo (Expo), license-dashboard (customer portal, phase 3).
4. The JSON payload: Rasd Form Definition (RFD) v1
Canonical JSON Schema lives at docs/schema/rasd-form.schema.json ($id: https://schemas.rasd.dev/form/v1.json, JSON Schema 2020-12). Rules:
- Property names are camelCase. Element
typevalues are snake_case (XLSForm-compatible where possible). - Localized string =
string | { [bcp47Locale: string]: string }. Anywhere a label/hint/message appears, both forms are legal. - Expressions are strings in REL (see §5). Fields that accept an expression are named
*ExprOR are documented asExpr-typed (relevant,required,readonly,constraint,calculate,default.expr,count,when). - Custom payload: every object node MAY carry
ext: { [vendorKey: string]: unknown }. Rasd validates it is an object and otherwise ignores/round-trips it. Vendor keys SHOULD be reverse-DNS or org slugs (e.g."ext": { "org.wfp.moda": { ... } }). - Custom element types are
"x:<name>"(e.g."x:beneficiary-lookup") and MUST be registered in the field registry of the renderer; unknownx:types render a placeholder, never crash.
4.1 Top-level shape
{
"$schema": "https://schemas.rasd.dev/form/v1.json",
"rasd": "1.0", // RFD spec version (semver-major.minor)
"id": "pdm-gfd-2026", // stable form slug (org-unique)
"version": "3", // monotonically increasing string; server rejects re-publish of same version
"meta": {
"title": { "en": "Post-Distribution Monitoring – GFD", "ar": "رصد ما بعد التوزيع" },
"description": "…", "tags": ["pdm"], "author": "…", "createdAt": "2026-08-15T10:00:00Z", "updatedAt": "…",
"ext": {}
},
"settings": {
"defaultLocale": "en", "locales": ["en", "ar"],
"navigation": "paged" | "scroll", // paged = one page per screen (default), scroll = single long page
"showProgress": true, "allowDrafts": true, "autosaveMs": 2000,
"instanceName": "concat(${hh_id}, ' – ', ${site})", // REL → human label for a submission
"submissionIdPrefix": "PDM",
"audit": { "enabled": true, "trackChanges": true, "location": { "enabled": false, "priority": "balanced", "minSeconds": 60, "minMeters": 50 } },
"encryption": { "mode": "none" | "field" | "submission", "publicKeyId": "…" },
"theme": { "themeId": "rasd-field", "overrides": {} }, // hint only; host may override
"ext": {}
},
"choiceLists": {
"yes_no": { "choices": [ { "value": "yes", "label": { "en": "Yes", "ar": "نعم" } }, { "value": "no", "label": "No" } ] },
"governorate": { "source": { "type": "dataset", "dataset": "geo_gov" }, "valueKey": "code", "labelKey": "name" },
"district": { "source": { "type": "dataset", "dataset": "geo_dist" }, "valueKey": "code", "labelKey": "name", "filterKeys": ["gov_code"] }
},
"datasets": [ { "name": "geo_gov", "source": "server" | "inline" | "url", "keyField": "code", "inline": [] } ],
"pages": [
{ "id": "intro", "title": {"en": "Consent"}, "relevant": "true()", "elements": [ /* Element[] */ ] }
],
"logic": {
"calculated": [ { "name": "hh_size_total", "calculate": "${adults} + ${children}" } ],
"triggers": [ { "id": "t1", "when": "${consent} = 'no'", "actions": [ { "type": "complete", "message": {"en": "Thank you"} } ] } ]
},
"ext": {}
}
4.2 Element (base shape — all element types)
{
"type": "select_one", // see §4.3
"name": "food_received", // ^[a-zA-Z_][a-zA-Z0-9_]*$ ; unique within its repeat scope; used as data key
"label": { "en": "Did you receive food?", "ar": "هل استلمت الغذاء؟" },
"hint": "…", "guidance": "…", // hint = under label; guidance = collapsible help
"media": { "image": "assets/food.png", "audio": "assets/food_ar.mp3" },
"required": true | "REL", // boolean or expression
"requiredMessage": "…",
"relevant": "REL", // skip logic; irrelevant ⇒ hidden AND value cleared on submit (ODK semantics)
"readonly": false | "REL",
"default": { "value": "yes" } | { "expr": "today()" },
"calculate": "REL", // if set, value is computed and read-only
"constraint": "REL", "constraintMessage": "…",
"validators": [ { "type": "regex", "pattern": "^[0-9]{9}$", "message": "…" }, { "type": "range", "min": 0, "max": 20 }, { "type": "length", "min": 2, "max": 100 }, { "type": "expr", "expr": "…", "message": "…", "severity": "warning" }, { "type": "custom", "id": "hostValidatorId" } ],
// validators[].severity: "error" (default, blocks finalize) | "warning" (shown, logged to audit, does not block) | "info"
"appearance": { "variant": "radio" | "dropdown" | "chips" | "buttons" | "likert", "columns": 2, "size": "lg", "ext": {} },
"bind": { "sensitive": false, "saveIncomplete": true, "trackChanges": true, "index": false, "ext": {} },
"props": { /* type-specific, see §4.3 */ },
"ext": { "org.example": { "kpi": "FCS-01" } }
}
4.3 Element types (v1)
type | Value type | Notable props |
|---|---|---|
text | string | multiline, format: "email"|"phone"|"url"|"none", maxLength, mask |
number | number | kind: "integer"|"decimal", min, max, step, unit, thousandsSeparator |
date / time / datetime | ISO-8601 string | min, max, calendar: "gregorian"|"hijri" (display only) |
select_one | string | list (choiceLists key) or choices[] inline, choiceFilter: REL, search, other: { enabled, label }, randomize |
select_multiple | string[] | as above + minSelected, maxSelected, exclusive: ["none"] |
rank | string[] | list/choices[] |
rating | number | max, icon: "star"|"number"|"smiley" |
range | number | min, max, step, showValue |
checkbox | boolean | (simple acknowledgement / yes-no toggle) |
consent | { granted: boolean, at: ISO, textVersion: string, locale: string, method: "tap"|"signature"|"verbal" } | text (localized consent statement), textVersion, method, allowWithdraw; always bind.sensitive-aware; drives data-protection audit |
matrix | { [rowValue]: value } | rows[], columns: { type: "select_one"|"number"|"text", … } |
geopoint | { lat, lng, alt?, accuracy?, capturedAt } | accuracyThreshold (m), autoCapture, allowManual, map |
geotrace / geoshape | Geo[] | mode: "manual"|"auto", intervalSeconds |
image | attachmentRef | source: "camera"|"gallery"|"both", maxPixels, quality, annotate, geotag, multiple, maxCount |
audio / video / file | attachmentRef | maxDurationSeconds, accept, maxBytes, multiple |
barcode | string | formats: ["qr","code128",…], allowManual |
signature | attachmentRef (PNG) | penColor, required |
note | — (display only) | style: "info"|"warning"|"success", collapsible |
hidden | any | default (typically from preload) |
calculate | any | requires calculate |
group | container | elements[], appearance.variant: "section"|"card"|"collapsible"|"field-list", relevant |
repeat | container ⇒ object[] | elements[], min, max, count: REL, addLabel, removeLabel, itemLabel: REL, keyField, confirmDelete, allowReorder |
x:<name> | host-defined | host-defined |
Value storage keys = element name; repeats produce arrays of objects; groups are transparent (they do not nest data unless props.nestData: true).
4.3a Additional settings keys (i18n)
settings.numbering: "latn" | "native" (digit display; inputs always normalise to ASCII on save; default latn inputs / locale-native display), settings.calendar: "gregorian" | "islamic-umalqura" (display only), per-locale settings.localeMeta[locale] = { dir: "rtl"|"ltr", numbering?, calendar? }. Localized strings may use the Rasd Mini-Message ICU subset ({var}, {n, plural, one {…} other {…}}, {x, select, …}, #, ' escaping) — nothing else.
4.3b Form versioning rules (normative)
versionis a string, monotonically increasing perid; a published version is immutable; the server rejects re-publishing an existing version and any change to a published version's content. Every stored definition carries adefinitionHash(SHA-256 of canonical JSON) and every submission recordsformVersion+definitionHash.- Element
nameis the storage key and is form-unique (within its repeat scope); moving an element between pages/groups is therefore non-breaking. Breaking changes (blocked by the builder/validator unless forced): changing an element'stype(except widening totext), reusing a retirednamewith a different type, changing a repeat'sname, removing a choice list still referenced. Warnings: removing elements, narrowing choices, tightening constraints. @rasd/coreexposesdiffDefinitions(from, to) → { changes, plan, loss }; drafts are migrated opt-in and plan-driven (migrateSubmission); finalized/outbox submissions are never migrated (they are the enumerator's attestation) and are accepted by the server against any published version.- The
rasdspec version isMAJOR.MINOR: consumers must ignore-and-preserve unknown properties within a MAJOR; MAJOR bumps ship a converter; deprecations get ≥ 12 months.
4.4 Preload metadata (available as ${meta.*} in REL)
meta.deviceId, meta.userId, meta.username, meta.startedAt, meta.now, meta.locale, meta.formVersion, meta.appVersion, meta.platform, meta.submissionId, plus host-provided meta.custom.*.
5. Expression language: REL (Rasd Expression Language) v1
- String expressions, statically parsable (Pratt parser in
@rasd/core), evaluated withoutevalin a sandbox. Dependencies are extracted statically for the reactive graph. - Field references:
${name}(XLSForm-compatible). Inside a repeat,${name}resolves to the sibling in the current repeat instance;${../name}to the parent scope;${/root_name}absolute;${repeat_name[2].field}explicit index (1-based like ODKindexed-repeat),${repeat_name[].field}yields an array. - Meta:
${meta.userId}. Dataset lookups:pulldata('geo_dist', 'name', 'code', ${district}). - Literals: numbers,
'strings'/"strings",true,false,null, arrays[1,2,3]. - Operators (precedence low→high):
or||→and&&→not!(prefix) →=,==,!=,<,<=,>,>=→+,-→*,/,mod,%→ unary-→ member/index/call. Ternarycond ? a : b.=and==are the same (loose XLSForm habit tolerated, compared by type-coerced equality rules documented in05-logic-and-expressions.md). - Core functions (v1, XLSForm-familiar):
if(c,a,b),coalesce(a,b,…),selected(multi, 'v'),selectedAt(multi, i),countSelected(multi),count(repeat),sum/min/max/avg(repeat[].field),position(),indexedRepeat(field, repeat, i),regex(str, pattern),contains,startsWith,endsWith,stringLength,substr,concat,join,upper,lower,trim,number,string,int,round,abs,pow,today(),now(),date,dateDiff(a, b, 'days'|'months'|'years'),formatDate,age(dob),uuid(),random(),pulldata,distance(geoA, geoB),area(geoshape),once(expr),jsonPath(obj, path),empty(x),notEmpty(x). Host apps add more viaregisterFunction(name, fn, { pure: boolean }). - ODK/XPath compatibility aliases: ODK's hyphenated function names are accepted only in call position (identifier immediately followed by
():string-length(≡stringLength(,count-selected(≡countSelected(,selected-at(≡selectedAt(,indexed-repeat(≡indexedRepeat(,date-diff((Rasd extension),format-date(≡formatDate(; ODK operatorsdiv(≡/),mod,and,or,not(x)and comparisons=,!=are supported.true()/false()are accepted as literals. Result: the large majority of real-world XLSFormrelevant/constraint/calculationstrings parse unchanged. XPath axes/paths beyond${x},../and/are NOT supported by the runtime (the importer rewrites or flags them). - Types: number, string, boolean, null, array, object, date (ISO string; comparisons on ISO strings work lexicographically), geo objects.
- Semantics (ODK-compatible): an element that is not
relevantis hidden, is not validated, and its value is excluded from the finalized submission (retained in draft so toggling back restores it).requiredis only enforced when relevant.calculatefields are recomputed whenever any dependency changes (topological order; cycles are a validation error at load time).constraintruns on change and on finalize;validatorsrun per the renderer'svalidateOnpolicy ("change"default,"blur","submit"). - XLSForm import maps XPath (
${x},selected(),if(),count(),../) to REL; unmappable expressions are kept verbatim underext["org.getodk.xpath"]with a validation warning.
6. Submission model (Submission, in @rasd/core)
{
"id": "0198c1a2-…", // UUID v7 (time-ordered) generated on device
"formId": "pdm-gfd-2026", "formVersion": "3",
"status": "draft" | "finalized" | "queued" | "sending" | "synced" | "rejected" | "conflict",
"data": { "consent": "yes", "hh_members": [ { "name": "…", "age": 34 } ], "photo": { "attachmentId": "…" } },
"meta": { "startedAt": "…", "finalizedAt": "…", "deviceId": "…", "userId": "…", "locale": "ar", "appVersion": "…", "platform": "android", "instanceName": "…", "geo": null, "ext": {} },
"attachments": [ { "id": "…", "field": "photo", "mime": "image/jpeg", "bytes": 182334, "sha256": "…", "localUri": "…", "remoteId": null, "status": "pending" | "uploading" | "uploaded" | "failed" } ],
"audit": [ { "t": "2026-08-15T10:01:02Z", "event": "value", "field": "consent", "old": null, "new": "yes" } ],
"clientRev": 12, "serverRev": null, "syncedAt": null, "createdAt": "…", "updatedAt": "…", "checksum": "sha256:…",
"ext": {}
}
Status transitions: draft → finalized → queued → sending → synced ; sending → queued (retry) ; sending → rejected (server 4xx with reasons; user must fix and re-finalize) ; editable-record pull-conflict ⇒ conflict (user resolves).
6.1 Submission checksum (normative — one definition, no doc may restate a different one)
checksum = "sha256:" + hex SHA-256 over the RFC 8785 (JCS) canonical JSON of an allowlist:
{ "id", "formId", "formVersion", "definitionHash", "data", "meta",
"attachments": [ { "id", "field", "mime", "bytes", "sha256" } ] } // sorted by attachment id
- Allowlist, never an exclusion list. Every omitted property changes while the payload does not —
statuswalksdraft → queued → sending → synced,serverRevis stamped on acknowledgement,clientRev/updatedAtmove on each local write,auditgrows,extmay be appended by the host. Hashing any of them makes a submission fail its own integrity check the moment the sync engine touches it (surfaced asRASD_STORAGE_INTEGRITY, docs/09 §13). An allowlist is also stable when a later spec MINOR adds a property. - Attachments are projected to file identity only:
localUri,remoteIdandstatusare device-local bookkeeping that changes as the file uploads, whilesha256does not. Sorted byidso two devices that captured the same files in a different order agree. meta.finalizedAtis covered (it lives insidemeta).- Computed synchronously by
engine.finalize(); verified by the sync engine before push and by the server, which recomputes it from the wire body and echoes it in the ack.
7. Storage contract (@rasd/storage)
interface StorageAdapter {
readonly kind: 'dexie' | 'sqlite' | 'memory' | (string & {});
open(opts: { namespace: string; encryptionKey?: CryptoKeyLike; migrations?: Migration[] }): Promise<void>;
close(): Promise<void>;
forms: FormRepo; // get(id, version?), listLatest(), put(def), delete(id, version)
submissions: SubmissionRepo;// get, put, patch(id, partial, {bumpRev}), list({formId,status,updatedSince,limit,cursor}), count, delete
attachments: BlobStore; // put(id, blob|uri, meta), get, getUri, delete, sizeOf, listByStatus
datasets: DatasetRepo; // putRows(name, rows, {since}), query(name, {filter, search, limit}), meta(name)
outbox: OutboxQueue; // enqueue(op), peek(n), ack(ids), fail(id, err, nextAttemptAt), size()
kv: KeyValueStore; // get/set/delete (license cache, sync cursors, device id)
transaction<T>(scope: ('forms'|'submissions'|'attachments'|'datasets'|'outbox'|'kv')[], fn: (tx) => Promise<T>): Promise<T>;
estimate(): Promise<{ usageBytes: number; quotaBytes: number | null; persisted: boolean | null }>;
import(chunks: AsyncIterable<ExportChunk>, opts?): Promise<ImportReport>;
state: 'closed' | 'opening' | 'open' | 'blocked';
on(event: 'change' | 'blocked' | 'quota' | 'error' | 'migration', handler): Unsubscribe;
// quota payload { usageBytes, quotaBytes, level: 'warning' | 'critical' }; migration payload { id, phase, done, total }
securityReport(): Promise<StorageSecurityReport>; // { encryption, keyStore, persisted, backupExcluded, ephemeral, notices }
export(): AsyncIterable<ExportChunk>; // JSONL + blobs — always available in every license state
wipe(): Promise<void>;
}
-
Encryption policy key:
encryption.atRest: 'off' | 'preferred' | 'required'(default'preferred'— encrypt when the platform supports it, warn loudly when it does not;'required'refuses to open unencrypted). -
Native DB key id in the secure store:
rasd.dbkey.<namespace>(namespaced so one device can hold two agencies' datasets). -
attachments.gc()skips ids prefixedbasemap:(offline map tiles are host assets, not submission attachments). -
Web:
@rasd/storage-dexie(Dexie 4, one database pernamespace, tablesforms,submissions,attachments,datasets,outbox,kv; blobs stored asBlobrows; requestsnavigator.storage.persist()). -
Native:
@rasd/storage-sqlite(tables mirror the above; JSON columns fordata; attachments as files in app documents dir with rows holding paths; SQLCipher when driver supports it; WAL mode).
7.1 Attachment size ceilings (normative — one table, no other doc may restate a different number)
| Limit | Value | Where enforced | On breach |
|---|---|---|---|
policy.submissionBudgetBytes — total attachment bytes per submission | 10 MB (default; host-configurable) | renderer, before finalize | Warn from 80 %, block finalize at 100 % with RASD_STORAGE_BUDGET |
Single blob, web (@rasd/storage-dexie, default blobs: 'indexeddb') | 25 MB hard cap | attachments.put() | RASD_STORAGE_QUOTA details.reason: 'attachmentTooLarge'; escape hatch blobs: 'opfs' |
Single file, native (@rasd/storage-sqlite, files on disk) | 100 MiB | attachments.put() | same code |
video.maxBytes default | 25 MB (was 50 MiB — lowered to stay within the web blob cap) | element props / builder | builder validation warning |
video.maxDurationSeconds default | 120 s (600 s at 720p ≈ 110 MB, unreachable) | element props | builder validation warning |
| Server-side per-attachment cap (RSP) | 100 MiB (configurable) | @rasd/server tus endpoint | HTTP 413 → re-chunk |
- Encryption at rest: web = AES-256-GCM via WebCrypto with a non-extractable
CryptoKeypersisted in IndexedDB (optionally wrapped by a host-supplied secret) — or unencrypted with a loud console warning; native = SQLCipher (whole DB;expo-sqliteuseSQLCipherorop-sqlite) with the key inexpo-secure-store/ Keychain / Keystore (never MMKV/AsyncStorage), plus field-level AES-GCM forbind.sensitivefields when whole-DB encryption is unavailable. Index columns stay cleartext; payload and media are encrypted. Integrity: SHA-256 checksums on submissions and attachments. - Storage never depends on Realm, WatermelonDB or RxDB premium storages (deprecated/unmaintained/paid — see research/04); optional adapters for wa-sqlite/OPFS (web, large datasets) and
react-native-nitro-sqlitemay be added behind the same interface.
8. Sync: Rasd Sync Protocol (RSP) v1 — REST + optional SSE
Base URL is host-configured. Auth is delegated to the host: getAuthToken(): Promise<string> returns a bearer token; Rasd never manages end-user identity. All requests carry X-Rasd-Device: <deviceId> and X-Rasd-Client: <lib>/<version>.
| Method & path | Purpose |
|---|---|
GET /v1/forms?since=<cursor> | Pull form definitions/versions assigned to this device/user (delta). |
GET /v1/forms/{id}/versions/{version} | Fetch one RFD document. |
GET /v1/datasets/{name}?since=<cursor> | Pull dataset rows (delta; tombstones). |
POST /v1/submissions:batch | Push finalized submissions (≤ 50 per call, Idempotency-Key header = batch hash). Response is per-item: accepted (with serverRev), duplicate, rejected ({code,message,field?}), conflict. |
POST /v1/attachments + PATCH /v1/attachments/{id} + HEAD | tus 1.0.0 resumable upload; Upload-Metadata carries submissionId, field, sha256. |
GET /v1/records?form=…&since=<cursor> / POST /v1/records:batch | Optional editable records / cases (longitudinal). Per-field last-writer-wins with server rev; conflicts returned to client with both versions. |
GET /v1/events (SSE) | Optional push: new form version, dataset update, record change. |
POST /v1/devices | Register device (id, platform, appVersion) → server policy (sync intervals, retention, remote-wipe flag). |
Sync engine (createSyncEngine({ storage, transport, getAuthToken, policy })): foreground-driven, single leader (Web Locks API on web so only one tab syncs; one engine instance on RN), single-flight, event-triggered (online, app foreground, finalize, manual) with priority order outbox submissions → attachments → forms pull → datasets pull → records; exponential backoff with full jitter (1 s → 5 min cap); connectivity signals from host (navigator.onLine, @react-native-community/netinfo) plus a HEAD probe; Background Sync API / expo-background-task are opportunistic accelerators only; events progress, error, conflict, formUpdated, datasetUpdated, licenseRefreshed; manual syncNow(), pause(), resume(); UI must always be able to show last sync time and pending counts. Integrity: SHA-256 per submission and attachment sent with the payload and echoed in the ack; published form definitions may be JWS-signed by the server. Records/cases use per-field last-writer-wins ordered by a hybrid logical clock (HLC) with a supervisor conflict queue.
9. Licensing: Rasd License Token (RLT)
- Token = compact JWS, alg
EdDSA(Ed25519), header{ alg, kid, typ: "RLT" }. Public keys are embedded in@rasd/license(rotatable viakid; new keys ship in minor releases; JWKS URL as fallback when online). - Claims:
iss:"rasd",sub:<orgId>,plan:"trial"|"starter"|"team"|"enterprise",features:[…](package/feature flags, e.g."builder","sync","native","xlsform"),apps:[ "https://*.example.org", "org.example.monitor" ](web origins with wildcards / RN bundle IDs; empty = any),seats?(soft, informational),iat,nbf,exp,grace(days afterexpduring which the SDK stays fully functional offline),enforcement: "soft"|"hard",jti. - Lifetimes: monthly plans →
exp= 60 days rolling (a fresh token is issued on every successful renewal/refresh),grace= 30 days ⇒ a device can be offline ~90 days without degrading; trial →exp= 7 days,grace= 0; annual/invoiced/enterprise →exp= 12 months,grace= 30 days (also usable as an offline licence file for air-gapped builds). - SDK states:
evaluating(no token, dev originlocalhost/127.0.0.1/*.local/Expo dev — full function, console notice) →trial(trial token or first-run 7-day local trial: full function) →active→grace→limited(soft(default): renderer keeps working with a visible "Unlicensed – Rasd Forms" watermark + console warning, builder is read-only, no new forms can be published;hard: renderer refuses to start new submissions but drafts can be finished, sync continues, and export of existing data always works) →invalid(bad signature ⇒ treated as no token). Field data collection is never silently lost because of licensing. - Where checks happen: verification is 100 % offline (embedded public key). Production end-user devices never contact Rasd servers. Refresh happens through the customer's own backend: (a) host-configured
tokenEndpointon the customer's server that proxies the Rasd License API with the org secret, or (b) piggy-backed on sync — the customer's RSP server may return a fresh token in theX-Rasd-Licenseresponse header. Direct refresh from a device with a public site key exists for prototyping only and is discouraged for production. Refresh when< 14 daystoexp; jittered; never blocks rendering. Clock tampering: monotonic guard (last-seen server time persisted; if device time < last-seen, freeze state and rely on grace). - Trial: 7-day trial token issued at signup (email, no card) — recommended, one per org domain; or zero-config first-run trial (device-local
kvtimestamp; a UX convenience, not security — no device fingerprinting). Optional policy: auto-extend trial to 30 days when onboarding milestones are hit (first form rendered, first sync). - Billing: Stripe Billing + Entitlements + Invoicing (POs, Net-30, tax-exempt invoices for UN agencies) → webhook → license service → issue/rotate tokens; MoR (Paddle/Polar) as an alternative for VAT-averse regions. Metering is per organization + apps (not per submission, not per end user).
- Business decision flagged for the founder: these docs implement the requested model — all runtime packages are token-gated after the 7-day trial (Option A). Research (research/06, research/10) recommends Option B — keep the renderer + offline storage free (OSI licence) and gate the builder, sync engine and enterprise features — for adoption and Digital-Public-Good eligibility. The token
features[]claim and per-package gating policy make either option a configuration change, not a redesign. See 15 · Licensing & billing.
10. Theming contract
Theme is JSON (docs/schema/rasd-theme.schema.json):
{
"id": "acme-field", "name": "ACME Field", "extends": "rasd-field", "mode": "light" | "dark",
"tokens": {
"color": { "primary": "#0B6EFD", "onPrimary": "#fff", "surface": "#fff", "onSurface": "#111", "surfaceVariant": "#F3F4F6", "outline": "#CBD5E1", "error": "#B3261E", "success": "#0F7B3F", "warning": "#B7791F", "focus": "#0B6EFD" },
"typography": { "fontFamily": "Inter, 'Noto Sans Arabic', system-ui", "fontFamilyRtl": "'Noto Sans Arabic', Inter", "baseSize": 16, "scale": 1.125, "weights": { "regular": 400, "medium": 500, "bold": 700 } },
"spacing": { "unit": 4 }, "radius": { "sm": 4, "md": 8, "lg": 12, "full": 999 },
"elevation": { "sm": "…", "md": "…" }, "motion": { "durationMs": 150, "reduced": "auto" },
"control": { "minTouch": 48, "height": 48, "borderWidth": 1 }
},
"components": { "Button": { "radius": "full" }, "SelectOne": { "variant": "buttons" } }, // per-component token overrides
"ext": {}
}
- Web: tokens → CSS custom properties
--rasd-<group>-<key>in kebab-case (color.onSurface→--rasd-color-on-surface,typography.baseSize→--rasd-typography-base-size), scoped on the provider root (.rasd-root[data-theme][data-color-scheme][data-contrast][data-density][dir]), never on:root. Lengths carrypx, durationsms, andtypography.baseSizeis emitted inremso the user's browser font-size setting and zoom still scale the form (WCAG 2.2 SC 1.4.4). Consumers build names withcssVarName()/cssVarRef()from@rasd/themesrather than hand-writing them. Default CSS ships in a@layer rasdso host CSS wins; every part has a stable classrasd-<Component>__<part>anddata-scope="rasd" data-part="<part>"attributes;classNames/styles/renderper part;unstyledprop turns off default CSS. - Native: same tokens →
StyleSheetviauseTheme()(plain RN StyleSheet; no styling-engine dependency in core; optional Unistyles/Tamagui adapters later); per-partstylesprop; font scaling honored viaallowFontScalingand tokentypography.maxFontScale(default 2.0). - Both:
componentsregistry ({ TextField: MyTextField, … }) andrenderersper element type ({ 'select_one': MySelect }) fully replace UI while keeping engine behavior; overrides must honor the accessible-props contract (label id, described-by, error state). - Modes are data:
light/dark/highContrast/reducedMotion/density, resolved from OS signals unless the host forces them.densityhas exactly three values:compact | comfortable | spacious(control heights 48 / 48 / 56;control.minTouchis 48 in every density and is clamped up to 48 at resolve time if a theme sets less). Root attributes on web aredata-theme,data-color-scheme,data-contrast,data-density,data-reduced-motion,dir; part attributes aredata-scope="rasd",data-part, anddata-component. RTL is automatic from locale (dir="rtl"on web /useDirection()on native — per-form RTL works even whenI18nManager.isRTLis false), using logical properties and mirrored directional icons. - Interchange:
fromDtcg()/toDtcg()convert to/from the W3C DTCG token format so Figma/Style-Dictionary pipelines can feed a theme;rasd theme check(CLI) validates the JSON and lints WCAG contrast; fonts (WOFF2 web / TTF native, incl. a default OFL Arabic family) are theme assets that must work offline.
11. Public API surface (names are frozen for docs)
// @rasd/core
createFormEngine(def: FormDefinition, opts?): FormEngine // headless: getState(), setValue(path, v), addRepeat(path), removeRepeat(path,i), moveRepeat(path, from, to), validate(), finalize(), subscribe(), toSubmission()
parseExpression(src): Ast; evaluate(ast, ctx); registerFunction(name, fn, meta)
validateFormDefinition(def): { ok, errors, warnings }
diffDefinitions(from, to): { changes, plan, loss }; definitionHash(def): string
migrateSubmission(sub, fromDef, toDef, plan?): Submission
// @rasd/react & @rasd/native
<RasdProvider storage license theme locale registry sync> … </RasdProvider>
<FormRenderer definition submissionId? initialData? onChange onFinalize onSave locale? readOnly? validateOn?/> // validateOn: 'change' (default) | 'blur' | 'page' | 'finalize'
useRasdForm(), useField(path), useSubmission(id), useSync(), useLicense(), useTheme(), useLocale(), useDirection(), useRasdBusy()
// useTheme() → ThemeView { theme, resolved, mode, setMode(), cssVar(path) } // `resolved` = resolved tokens for the active mode
defineElement({ type: 'x:foo', component, builder?: { icon, label, inspector }, valueSchema? })
// @rasd/element
defineRasdElement(tag = 'rasd-form') // registers the custom element (distinct from defineElement, which registers an RFD element type)
<rasd-form definition-url="…" locale="ar" dir="rtl"></rasd-form> // custom element; events: rasd:change, rasd:finalize, rasd:error
// @rasd/themes
createTheme(partial, { extends: 'rasd-field' }), fromDtcg(tokens), toDtcg(theme), rasdLight, rasdDark, rasdHighContrast, rasdField
// @rasd/builder
<FormBuilder definition onChange onPublish plugins locale theme features={{ logic:true, translations:true, json:true, versions:true }} />
// @rasd/storage
createMemoryStorage(opts?) // in-memory adapter: tests + <RasdProvider> no-storage fallback
runConformanceSuite(factory, opts?) // storage-adapter contract tests (re-exported by @rasd/testing)
// @rasd/storage-*
createDexieStorage({ namespace, encryptionKey? }), createSqliteStorage({ driver: 'expo'|'op'|'better-sqlite3', namespace, encryptionKey? })
// @rasd/sync
createSyncEngine({ storage, baseUrl, getAuthToken, policy, transport? })
// @rasd/license
createLicense({ token?, tokenEndpoint?, siteKey?, storage, freeFeatures? }) → { state$, getState(), getSnapshot(), setToken(), refresh(), on() }
// InvalidReason (frozen, SCREAMING_SNAKE): MALFORMED | WRONG_TYP | UNKNOWN_KID | REVOKED | BAD_SIGNATURE | NOT_YET_VALID | APP_MISMATCH | SCHEMA | ISSUER
// @rasd/pwa
registerRasdRoutes(workbox opts); useInstallPrompt(); useServiceWorkerUpdate(); useStoragePersistence(); requestPersistence()
// @rasd/testing
renderForm(def, opts?), createFakeClock(), createFaultyTransport(opts), assertAccessibleContract(Component), fixtures
12. Cross-cutting conventions
- IDs: UUID v7 for submissions/attachments/records; slugs for forms;
namefor elements. - Time: ISO-8601 UTC strings on the wire and in storage; local display via
Intl. - Errors:
RasdErrorwithcode(RASD_SCHEMA_INVALID,RASD_EXPR_PARSE,RASD_STORAGE_QUOTA,RASD_SYNC_REJECTED,RASD_LICENSE_EXPIRED, …),message,details,cause. - Events: all engines expose
on(event, handler)/subscribe()returning an unsubscribe function; React hooks wrap them. - Bundle budgets (min+gzip, excluding React):
@rasd/core≤ 45 kB (REL parser+stdlib ≤ 10 kB of that);@rasd/reactdefault renderer ≤ 90 kB (target ≤ 60 kB);@rasd/license≤ 12 kB; form-runner total (core + react + storage-dexie + sync + license) ≤ 120 kB;@rasd/builderis a separate lazy chunk (dnd-kit adapter ≤ 25 kB); media adapters, locales and heavy element types (matrix, geo, signature) are code-split per capability. Enforced withsize-limitin CI. - Toolchain (from research/08): pnpm 11 workspaces + Turborepo; TypeScript 6.x
strict+isolatedDeclarations+verbatimModuleSyntax; ESM-only packages ("type": "module", extension-less.jsoutput, no dual CJS); web/core packages built with tsdown (Rolldown), native packages with react-native-builder-bob;exportsmaps withreact-nativecondition first,typesfirst-within-branch, plus a privaterasd-sourcecondition for in-repo dev; platform split by package (@rasd/reactvs@rasd/native), not by file extension;sideEffects: falseexcept CSS/theme files; Changesets + npm Trusted Publishing (OIDC) with provenance; Vitest 4 + RTL +vitest-axe, Jest + RNTL 14, Playwright (incl. offline mode & RTL projects), Maestro (Expo), Storybook 10; ESLint 9 flat (typescript-eslint, react-hooks, jsx-a11y) + Biome formatter; Docusaurus 3.10 docs (en/ar RTL/fr). - Support matrix (launch): React 19 (primary;
@rasd/reactalso tested against 18.3); React Native ≥ 0.81, New Architecture only & Expo SDK ≥ 54 (peers: RN, optionalexpo-sqlite,@op-engineering/op-sqlite, RNGH 3 / Reanimated 4 for the native reorder UI); Android 7+ (API 24) native, Android WebView/Chrome ≥ 100 web; browsers: last 2 versions of Chrome/Edge/Firefox/Safari; feature-detect IndexedDB, WebCrypto (Ed25519), BarcodeDetector, Background Sync. - Licensing of the code: open-core —
@rasd/core,@rasd/xlsform,@rasd/testing,@rasd/cli,@rasd/themesunder Apache-2.0;@rasd/react,@rasd/native,@rasd/element,@rasd/builder,@rasd/storage*,@rasd/sync,@rasd/pwa,@rasd/media,@rasd/license,@rasd/serverunder a source-available commercial license (FSL-1.1-Apache-2.0) that requires a valid RLT for production use after the trial (source converts to Apache-2.0 after 2 years under FSL). See the Option A/B note in §9. (Documented in15-licensing-and-billing.md.)
13. Vocabulary (use these words consistently)
| Term | Meaning |
|---|---|
| Form definition / RFD | The JSON document describing a form (FormDefinition). |
| Element | Any node in pages[].elements (question, note, group, repeat). "Question" = element that stores a value. |
| Submission | One filled instance of a form (draft → finalized → synced). |
| Record / case | An editable, longitudinal entity (optional feature) distinct from an append-only submission. |
| Dataset | Tabular reference data preloaded to the device (choice lists, lookups). |
| Attachment | A binary captured by a question (photo, audio, signature, file). |
| Renderer | @rasd/react or @rasd/native UI that drives the engine. |
| Builder | The drag-and-drop editor for RFDs. |
| Registry | Map from element type to component (+ builder metadata). |
| Outbox | Local queue of operations to be synced. |
| License token / RLT | Signed token that unlocks the library after trial. |
| Host (app) | The customer's website/app that embeds Rasd Forms. |
14. Amendment log
The spine is amended when a design document surfaces a decision the spine did not cover. Each amendment lists what changed so downstream docs can be re-checked.
| Date | Amendment |
|---|---|
| 2026-08-15 | Initial spine (§1–§13). |
| 2026-08-15 | Grounded in research: @dnd-kit/react pinned + WCAG 2.2 SC 2.5.7 non-drag equivalents; @rasd/element package added; ODK/XPath function aliases in REL; consent element type; validator severity; licence lifetimes (60 d + 30 d grace) and the "production devices never phone home" rule; theming scoped CSS vars + DTCG interchange; ESM-only/tsdown/bob toolchain; RN ≥ 0.81 New Architecture; form-versioning rules (§4.3b); i18n settings (§4.3a). |
| 2026-08-15 | Post-review ratifications: FormEngine.moveRepeat(); createMemoryStorage() + runConformanceSuite(); useStoragePersistence() / requestPersistence(); defineRasdElement(); ThemeView.cssVar(); @rasd/testing surface incl. assertAccessibleContract(); density = compact|comfortable|spacious + ratified data-* attributes; rasd doctor and rasd license CLI commands; RasdSecurity ships inside @rasd/native (no @rasd/native-security); §7.1 normative attachment-size table; encryption.atRest values + rasd.dbkey.<namespace>; StorageAdapter gains import/state/on/securityReport; @rasd/license ≤ 12 kB budget. |
| 2026-08-28 | REL fuzzing (E0.4) — 65,000 generated inputs per run assert the shape contract, not values: a parse failure is RASD_EXPR_PARSE or nothing, evaluation never throws into the renderer, and both terminate. Verified to have teeth by removing the depth guard, which it catches as RangeError. It surfaced one thing worth writing down: not is looser than comparison (§6) and that is deliberate, so ${a} = not(${b}) cannot parse — the generator was wrong, not the parser. Nothing imported from ODK is affected (@rasd/xlsform emits (not ${a}) parenthesised and it round-trips stably); it is the person typing by hand who meets it, so the parse error now names the fix instead of saying "expected an expression". |
| 2026-08-28 | The bundle budgets in §12 are measured for the first time (tools/check-size.mjs, in CI). Every one is met — but the measurement had to be corrected before it meant anything: bundling the whole public surface put @rasd/core at 48.2 kB, over its 45 kB, and 30 % of that was the zod-backed validator that core's own entry point documents as something the runtime never imports. Measured as a consumer actually imports it, the runtime is 29.0 kB, the form runner 98.7 kB of 120, and @rasd/react 69.3 kB — inside its 90 kB budget, above its 60 kB target. The whole-surface number (48.1 kB) is reported as information, because it is the right answer to "how big is this package" and the wrong one to "what does an enumerator wait for". |
| 2026-08-28 | Versioning & migration (E0.5) landed. diffDefinitions / planMigration / migrateSubmission ship in @rasd/core, with rasd diff --plan-out in the CLI. Three ratifications from building it: (1) one flattener — flattenDefinition in core/migrate is now the single answer to "what is the same field across versions", and checkPublishRules imports it; three independent implementations (validator, builder panel, spec) is how a builder tells an author a change is safe while the validator refuses to publish it. (2) Renames are inferred in two passes — exact-shape first, then same-type/same-scope ignoring labels and hints, but ONLY when exactly one candidate exists on each side; ambiguity resolves to "no rename", never a coin flip. (3) migrateSubmission refuses non-drafts unless forced, is atomic, and moves a removed question's answers to meta.custom.orphaned rather than deleting them. The builder's diffDefinitionsLocal stand-in is deleted; BuilderDiff is now DiffResult. |
| 2026-08-28 | Sync robustness (data-loss fix). A 2xx carrying non-JSON is a captive portal, and is now classified as a retryable RASD_SYNC_NETWORK error, not a 422. As a 422 it classified rejected, which dead-letters every submission in the batch — status rejected, retry parked in the year 9999 — so a field team uploading over hotel or office wifi lost up to 50 finalized interviews to a login page, and logging in did not bring them back. SyncTransport gains an optional probe() (RSP: GET /v1/ping, 2 s budget, redirect: 'manual') which the engine calls before it will push, so a portal parks the queue instead of feeding it a login page; a transport without one keeps the old behaviour. runPass now runs under Web Locks (ifAvailable, feature-detected, origin-wide name by default, lockName to override) so two tabs cannot both sweep sending rows, share an attempts counter or upload every attachment twice — the "single-flight guarantees no live pass owns it" assumption is finally true. RasdProvider wires sync.on('licenseRefreshed') → license.setToken(): both halves existed and nothing connected them, so a server-renewed subscription was delivered on a response header and discarded, dropping a paying customer to limited on the day the old token expired. |
| 2026-08-28 | Implementation ratifications (from building pwa, xlsform, testing, cli, element): shell and font cache lookups pass { ignoreVary: true } — cache.add() stores under a no-cors request (no Origin) while a module script is fetched cors (with one), so a host emitting Vary: Origin makes its own precached shell unmatchable and the app paints blank offline (§11 of 11); definition caches keep default Vary semantics. assertAccessibleContract(contract, rendered, opts?) takes the props object and the rendered node, not a component — the props-to-node comparison is the actual failure mode, and this signature needs neither React nor a DOM, so the one helper works under Vitest + RTL and Jest + RNTL alike. @rasd/testing 0.1 ships the five §11 names headless; the RTL variant, fakeStorage(), fakeMedia(), fakeLicense() and faultyNetwork() remain unbuilt (17 §14.1). createFaultyTransport() wraps a SyncTransport rather than standing one up — @rasd/server is already the reference implementation, and a second in-memory verdict engine would be a second source of truth about what the protocol accepts. @rasd/cli: --version is not a global flag (§13 gives it to convert xlsform for the form's version; the CLI's own version is the rasd version command), license check gains --key <kid>=<base64url> so a team running the reference issuer can verify its own tokens offline, and a token that fails to verify reports invalid rather than the evaluating that deriveState returns for a node host — dev-machine leniency is the right answer inside an app and the wrong one in a release pipeline (17 §13.1). @rasd/element: <rasd-form> mounts React into a <div part="root"> inside the shadow root rather than onto the root itself — a part attribute is only meaningful on an element, and §16 makes ::part(root) a required test, so createRoot(shadowRoot) would put the documented styling handle out of reach. defineRasdElement() mints a fresh subclass per call (one constructor cannot be registered under two tag names) and its double-load check is per tag, not against the literal 'rasd-form'. RasdProvider gains **`tokens: 'inline' |
| 2026-08-28 | Example corpus (E0.2) — and two validator bugs it found. The corpus is now five forms (pdm-gfd-2026, the facility visit, a WASH water point survey in en/fr, an Arabic-default nutrition referral, and the generated pdm-300 benchmark) plus fifty negative fixtures, one per error code, each carrying exactly one violation. Two of them are enforced backwards: every E_ code in validate.ts must have a fixture, and the pdm-300 generator is re-run in CI and diffed. That direction is the whole point — a corpus otherwise only proves things about rules someone remembered to write a file for. Switching it on found (1) validateFormDefinition was not parsing expressions. It ran a lexical scan, which catches unbalanced parentheses and unterminated strings but cannot see grammar, so relevant: "1 +", "1 2 3" and a bare "and" all passed rasd validate clean and then failed at createFormEngine() — on a phone, in the field. The definitive parser now runs at validation time too (free: the engine parses the same sources anyway and parseExpression caches by text). (2) E_CONSENT_TEXT_MISSING depended on how far the author had got. With no props at all the semantic pass raised it; with "props": {} half-filled, zod rejected first and the author saw E_TYPE_PROPS_INVALID instead — a different code for the same mistake. The zod issue is now mapped to the documented code. Also: fourteen limit guards had never been fired by any test (E_TOO_MANY_PAGES, E_NESTING_TOO_DEEP, W_MANY_TRIGGERS, …); all fifty codes now are, via --limit and --features, two new rasd validate flags that map onto options the function already took. NFR-002 is met with room to spare: createFormEngine(pdm-300) is 3.8 ms median against a 150 ms desktop budget, and parse + validate + engine is 15.5 ms against the 1,000 ms device budget — though that is desktop silicon, and the device half still needs the Phase 4 lab. |
| 2026-08-28 | E1.1 element coverage: geotrace/geoshape and SearchSelect. Both geometry types rendered the "unsupported" placeholder on the grounds that they need a map — which 14 §4.4 contradicts in as many words ("the map is optional everywhere") and for which it specifies the no-map interaction. They now render: Add point here / auto mode with the 2 m jitter filter, undo, Close shape appending the first vertex for §10.11's closed ring, and live length/area from pathLengthMeters / ringAreaMeters, now exported from @rasd/core so the number on the enumerator's screen and the number distance() puts in the data come from one implementation. Every built-in element type now renders a real control, asserted over the whole catalogue rather than a list. Long and dataset-backed lists resolve to a SearchSelect combobox (WAI-ARIA 1.2, aria-activedescendant so focus never leaves the input and a phone keyboard never closes under the list), matching through a new normalizeForSearch in core — NFKC, tashkeel and tatweel stripped, أإآٱ→ا, ى→ي, ة→ه, the Persian/Urdu ک/ی variants folded, digits mapped — because an enumerator typing مافرق on whatever keyboard the phone has must find المَفْرَق as the dataset spells it. A spec conflict surfaced and needs settling: 04 §10.4 makes props.search default to "auto (> 12 choices)" while 06 §5 and §11 both say the component swaps at 15. The implementation follows 12, on the grounds that the schema owns the prop's default; one of the two documents should change. Still open in this area: select_multiple does not get the combobox (a multi-select needs chips and the exclusive interaction, which is its own component), and neither the listbox windowing nor the datasets.query push-down of §11 is built — both are 50,000-row concerns, not correctness. |
| 2026-08-28 | Code-splitting the heavy element types (E1.1, 06 §16) — and a measurement that was lying twice. matrix, rank, the three geometry types, every capture type and SearchSelect now load through import(), with the entry points 06 §16 names (@rasd/react/elements/{matrix,rank,geo,media,barcode,signature,search-select}) and a working preloadElements(). React.lazy is deliberately not used: its payload only records a resolution once React has rendered the lazy component, so awaiting the same import beforehand still suspends the first render — which is precisely what warming a chunk was supposed to prevent. The module cache lives in lazy.tsx, so after preload() the component renders synchronously and a PWA that warms its chunks paints the form on first try. Two things had to be true before the split did anything at all, and both were found by measuring rather than by reasoning: a component re-exported from ./components/index.js or from the package root is statically reachable, and a bundler will not defer what it can reach — going through those barrels left 81.8 kB loading before first paint against 1.1 kB in chunks, i.e. no split whatsoever. tools/check-size.mjs was also measuring the wrong thing twice over: without splitting: true esbuild inlines every import(), and with it, esbuild marks every dynamic-import target as an entry point — so "the first output with an entryPoint" picked a 131-byte shim and reported a delightful 22.5 kB. It now walks import-statement edges from the <stdin> entry and reports the deferred chunks in a separate column, because a budget met by moving bytes into something every form fetches a moment later is a measurement congratulating itself. Honest result: 76.3 kB → 66.8 kB before first paint, 15.0 kB deferred. The ≤ 60 kB target is still missed, and the remaining item is the one §16 also names — the locale catalogues, 26 kB of messages.js. That split is NOT done, because resolveCatalog is synchronous and an Arabic form briefly rendering English chrome is worse than 6 kB. |
| 2026-08-28 | The accessibility contract and CSP (E1.1). Four findings, all confirmed against the documents. (1) Nothing in @rasd/react ever emitted a lang attribute — 13 §8 requires it beside dir and §11 maps it to WCAG 2.2 SC 3.1.1/3.1.2 — so an Arabic form reached a screen reader with no declared language and was read with an English voice. Now on both the provider root and the form, because a form can carry a locale its page does not. (2) Read-only was disabled, which 06 §9 forbids in as many words; every radio, checkbox, slider, rating and matrix cell left the tab order and, on several screen readers, the accessibility tree, so a supervisor reading back a finalized submission heard the questions and none of the answers. readOnlyControlProps keeps them focusable and announced and stops the interaction at the click. Buttons stay disabled — there is no value on "Take photo" to read. (3) inputProps gained inputMode and autoComplete; the latter is read only from bind.ext['dev.rasd.ui'] and never inferred, because an enumerator's browser autofilling THEIR name into a respondent's field is a data-protection incident. aria-labelledby is deliberately NOT spread, though §4 lists it: it OVERRIDES a <label for> association rather than supplementing it, so adding it renamed every sub-labelled control to its field label — BarcodeField's manual entry went from "Type the code" to "Card". It stays on the type because the contract is what a REPLACEMENT must honour, and FieldWrapper now always emits a node carrying ids.label so the reference resolves. (4) Theme tokens moved off the inline style attribute to a scoped <style> with cssNonce (§15, §2.1). A strict style-src 'self' 'nonce-…' — the policy 16 asks hosts to set — blocks inline style ATTRIBUTES outright and no nonce can excuse one, so the previous default made every token silently vanish under exactly the CSP this project recommends. `tokens: 'sheet' |
| 2026-08-28 | Trigger actions and useSubmission (E1.1) — the last of the nine blocking audit findings. The renderer subscribed to engine.subscribe() (value patches) and called engine.on() nowhere, so every trigger action that is not a value write did nothing at all: skipTo jumped nowhere, complete completed nothing, showMessage showed no message, and a custom action reached no host. Core had always emitted them — engine.ts even carries a comment saying navigation is the renderer's business and the trigger event carries it — and the far end simply was not there, which is a failure with no error to raise. Now wired, with onComplete and onTriggerAction on FormRendererProps; skipTo is a no-op in scroll navigation, as 04 §8 says, because every page is already on screen. useSubmission(id) exists at last — E1.1 named it among its four hooks — following the adapter's change feed rather than polling, so a draft list refreshes when autosave commits or sync stamps a status, and staying loading for ever without storage because "the database has not opened" and "there is no such draft" are different answers and only one is true. That closes all nine blocking findings from the E1.1 audit. |
| 2026-08-28 | The other free-text option now exists, and it needed a core change (04 §10.4/§10.5, XLSForm or_other). validate.ts had always RESERVED the <name>_other name and reported E_IMPLICIT_KEY_COLLISION when an author took it, so the schema half was there — while the compiler created no node for it, setValue('q_other', …) wrote to a path the engine did not have, and neither select rendered the option. A form offering "Other (please specify)" recorded the choice and lost the specification: the one answer nobody can reconstruct afterwards, because it is by definition the one nobody anticipated. compileForm now materialises the sibling as a real text node whose relevance is the parent holding the other value (selected() for a multi-select), so abandoning the choice clears the text through §9's ordinary irrelevant-value rule rather than through bookkeeping in the renderer. Two things this exposed: buildDataTree walks the authored element names, so a generated node had to be emitted explicitly beside its select — and it was also skipped by the "preserve unknown keys" rescue precisely because it IS in byPath; and REL string literals have no escape sequences (05 §2), so a choice value containing both quote characters cannot be written as a literal at all — that case leaves the sibling always relevant rather than never, because showing a text box that should be hidden is a nuisance and hiding one that should be shown loses an answer. |
| 2026-08-28 | settings.numbering and settings.calendar now do something (04 §4.1, 13 §5/§11). createLocaleConfig had always computed numbering, numberingSystem and calendar — honouring localeMeta per locale — and every formatter then constructed Intl from the bare locale and discarded all three. The nutrition form in the example corpus declares both and was formatted as though it declared neither. New intlLocaleFor(config) in core attaches them as Unicode extensions (-u-nu-…, -u-ca-…), and FormRenderer builds its own Intl from it rather than delegating to the provider's — the provider has no form, so it has no settings to honour. latn is never appended: it is already Intl's default and an unnecessary extension only makes a tag harder to read. Date fields gained the Hijri readout §5 asks for, marked lang="ar" per §11 because a Hijri month name is Arabic whatever language the form is in; the stored value stays ISO Gregorian (§4.1). A Hijri PICKER remains deferred — the browser's own picker is Gregorian and cannot be told otherwise. |
| 2026-08-28 | Per-part classNames / styles overrides (E1.1, 12 §5.1, 06 §15) — the documented way to restyle this library did not exist. grep -rn classNames packages/react/src returned nothing, so a host putting Rasd inside their own design system had exactly one option: out-specify the stylesheet in CSS. Both props now exist on RasdProvider and FormRenderer, keyed by component then part (part names like root and input repeat), accepting a value or a function of the part's state — the same state already published as data-*. Layers ADD rather than replace, default class first, so a host class wins the cascade at equal specificity. Three contract fixes came with it: every root now carries the rasd-<Component>__root class §5.1 names without exception (the bare component class stays, because the stylesheet uses it); data-component is on every part, not only roots, so the [data-component][data-part] selector §5.1 invites matches something; and part names were aligned to the published table in §5.2 (requiredMarker, labelRow, message) from the kebab-case the components had drifted to — §5.1 makes renaming a part a MAJOR change, so pre-1.0 is the only time to do it. FieldWrapper also renders the guidance/guidanceToggle parts at last: useField resolved guidance and FieldApi published it, and no component drew it, so an author writing XLSForm's guidance_hint was writing into a void. render — §5.1's third member — is deliberately NOT implemented: it REPLACES a part's markup rather than decorating it, so it must receive and re-spread every id and ARIA attribute the accessible-props contract puts there, and getting that wrong silently breaks a form for the people who most need it to work. classNames/styles are wired through FieldWrapper — which covers every field's root and all wrapper parts — and FormRenderer's root; the inner parts of individual field components are not yet wired. |
| 2026-08-28 | appearance.variant and the matrix Likert grid (E1.1). SelectOne accepted three of the five variants 04 §10.4 defines and coerced chips and likert to undefined; SelectMultiple read element.appearance nowhere at all; and a select_one matrix column rendered a dropdown per row where §10.10 says "the choices are the visual columns (Likert grid)" — a five-point scale across eight services was eight menus to open, on a phone, in the sun. All three fixed, and 06 §5's missing half — "unsupported variants fall back to the component default with a dev warning" — now happens, once per component+variant so a 200-row repeat does not shout 200 times: without it an author cannot tell a variant that is not implemented from one they misspelled. The variants are CSS on data-variant rather than separate components, because chips, buttons and likert are one radio group with three arrangements — identical semantics, different layout; the stylesheet had a rule for buttons alone, so the other two reached the DOM and nothing acted on them, which is the same silence as not reading the prop. Also corrected an off-by-one: §10.4 says the radio→dropdown switch is at > 6 choices and the code used > 7. rows[].relevant is still not honoured — evaluating it needs an expression-evaluation API FormEngine does not expose, so it is a core change rather than a renderer one, and it is better named than quietly skipped. |
| 2026-08-16 | Implementation ratifications (from building core, themes, storage, storage-dexie, react): §6.1 submission checksum is an allowlist (was an exclusion list — the old rule broke the hash the moment sync moved a submission); CSS variables are kebab-case and typography.baseSize emits rem (WCAG 2.2 SC 1.4.4); FieldIssue gains messageKey/messageVars so renderers can localise core's built-in validation messages (core is headless and ships no catalogue); encryption.atRest default 'preferred'; storage on() covers change|blocked|quota|error|migration. |
Open questions
- Licensing Option A vs Option B (§9) — founder's decision. Option A (as requested) gates every runtime package after the 7-day trial; Option B (research-recommended) keeps the renderer + offline storage OSI-licensed and gates the builder, sync engine and enterprise features. This affects Digital Public Goods eligibility, the pricing page and the
features[]claim, but not the architecture. - Trial length — 7 days as requested; every peer in the market runs 14–30 days (research/10). The optional "auto-extend to 30 days on onboarding milestones" rule in §9 is the compromise; confirm or drop it.
- Name — "Rasd" and the
@rasd/*scope are placeholders; npm-scope availability and trademark screening are unresolved. - Records / cases (§8) — shipped as a phase-3 optional feature; whether it should instead be core (ODK Entities / CommCare cases are the incumbent differentiator) is open.
- Collaborative builder editing (Yjs) — designed but deferred to phase 3; revisit if pilots ask for it.
Related documents
Every document in this set conforms to this spine: 01 · Vision & scope · 02 · Requirements · 03 · Architecture · 04 · Form schema spec · 05 · Logic & expressions · 06 · React renderer · 07 · React Native renderer · 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 · Schemas