Skip to main content

Decisions

Rasd Forms records its decisions as amendments to the spine rather than as separate ADR files, so each one sits next to the convention it changed. This page is generated from that log — 18 records, oldest numbered 1.

Still open

These are not oversights. Each needs a decision from the founder rather than more engineering, and the first three are blocking work that is otherwise ready.

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.

Settled

18appearance.variant and the matrix Likert grid (E1.1)

`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.

17Per-part classNames / styles overrides (E1.1, 12 §5.1, 06 §15) — the documented way to restyle this library did not exist

Per-part `classNames` / `styles` overrides (E1.1, [12 §5.1](12-theming.md), [06 §15](06-renderer-react.md)) — 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.

16settings.numbering and settings.calendar now do something

`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.

15The other free-text option now exists, and it needed a core change

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.

14Trigger actions and useSubmission (E1.1) — the last of the nine blocking audit findings

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.

13The accessibility contract and CSP (E1.1)

The accessibility contract and CSP (E1.1). Four findings, all confirmed against the documents. (1) Nothing in `@rasd/react` ever emitted a `lang` attribute13 §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' | 'inline' | 'none', default sheet; verified in a browser (no --rasd- in the attribute, tokens computing, lang/dir correct in both locales).

12Code-splitting the heavy element types (E1.1, 06 §16) — and a measurement that was lying twice

Code-splitting the heavy element types (E1.1, [06 §16](06-renderer-react.md)) — 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.

11E1.1 element coverage: geotrace/geoshape and SearchSelect

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](04-form-schema-spec.md) makes `props.search` default to "auto (> 12 choices)" while [06 §5 and §11](06-renderer-react.md) 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.

10Example corpus (E0.2) — and two validator bugs it found

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.

9Implementation ratifications (from building pwa, xlsform, testing, cli, element)

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' | 'none'` and FormRenderer gains `onReady(api)`: the element needs tokens in a :host sheet rather than inline (an inline declaration on a descendant kills host piercing) and needs FormApi from outside React, which useRasdForm() cannot provide because the context is created by FormRenderer itself. The IIFE size budget in §14 is not met — measured 186–191 kB gz against 175 kB (11 §14.1 records the four ways out).

8Sync robustness (data-loss fix)

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.

7Versioning & migration (E0.5) landed

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 flattenerflattenDefinition 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.

6The bundle budgets in §12 are measured for the first time

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".

5REL fuzzing (E0.4)

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".

4Implementation ratifications (from building core, themes, storage, storage-dexie, react)

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.

3Post-review ratifications

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.

2Grounded in research

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).

1Initial spine (§1–§13)

Initial spine (§1–§13).