Skip to main content

12 — Form-definition versioning, RFD spec evolution, and migration of submissions, records and local databases

Research note for Rasd Forms (gap #2). Researched 2026-08-15 from primary docs (ODK Central/Collect, KoboToolbox + formpack source, SurveyCTO, CommCare, Form.io, Avro/Protobuf/Confluent, RFC 6902/7386, JSON Schema, Kubernetes deprecation policy, Dexie, Expo SQLite, Drizzle, Prisma, SQLite). Access date for all sources: 2026-08-15. Items that could not be verified from a primary page are flagged [unverified].

Summary

  • Every incumbent pins a submission to the form version it was started with, and none of them migrates in-flight data automatically. ODK Collect explicitly opens edits with "the version of the form that was active when the form was originally filled, NOT the latest one" [6], and opening an old draft in a newer version "is not functionality that's currently available" (ODK team, 2025-03-24) [9]. CommCare does the opposite (incomplete forms load in the new app version) and documents force-closes when a removed choice is encountered [22] — a cautionary example.
  • Servers accept old-version submissions as long as the version was published and the form not deleted: "Central will accept submissions for any combination of form_id and version that it has published and that has not been deleted" [8]. Central rejects a draft whose schema "contains any field path which overlaps with a previous version, but with a different data type" (except conversion to text) and rejects reused version strings [1][2].
  • Renaming, moving into a group, or deleting a question is treated everywhere as delete + add. SurveyCTO: "A renamed field is treated as deletion plus addition"; group changes implicitly rename all nested paths [19]. Kobo lists "Changing a question's data column name" and "Moving questions into or out of groups" among changes to avoid [12]. Kobo's exporter (formpack) keys each row by __version__ and silently skips rows whose version is unknown (# TODO: somehow include this submission anyway) [14][15].
  • Exports need the union of fields across versions: Central "include previously deleted fields" [5], Kobo "Include data from all versions" (default on) [13], SurveyCTO "Export missing data" and "ignore groups" [19]. Kobo cannot export a single version's data natively (workaround: redeploy old version) [16].
  • The right theoretical frame is Avro/Confluent compatibility: reader-has-field-writer-doesn't needs a default; writer-has-field-reader-doesn't is ignored; enum symbol unknown needs an enum default; renames need aliases [24]. Confluent's BACKWARD (consumers first) vs FORWARD (producers first) maps onto "server/exports first" vs "devices first" [25]. Protobuf's "preserve unknown fields" and "reserve deleted numbers" [26] map onto "must-ignore-but-preserve" and "never reuse a retired name".
  • Kubernetes' deprecation rules are the best model for the rasd spec version: elements "may only be removed by incrementing the version", objects "must be able to round-trip between API versions... without information loss", and stable versions live for a whole major [34]. SemVer supplies MAJOR/MINOR meaning and "released contents MUST NOT be modified" [33].
  • Local DB migrations: Dexie runs upgraders sequentially in one transaction ("If any error occur in any upgrade function in the sequence, the upgrade transaction will roll back"); you must keep every version that has an upgrader "as long as there are code out there that use a version lower" [35]; multi-tab upgrades fire versionchange (Dexie closes and warns by default) and blocked [37][38]. Expo SQLite's documented pattern is PRAGMA user_version in SQLiteProvider.onInit, withExclusiveTransactionAsync, and backupDatabaseAsync [44]; Drizzle tracks applied SQL migrations in __drizzle_migrations by hash [42][43]; SQLite DDL is transactional and arbitrary table rewrites follow the documented 12-step procedure [45].
  • Immer patches are op/path-array triples (add/replace/remove, no move/copy/test) and "not guaranteed... optimal" [30]; RFC 6902 adds move/copy/test and atomic all-or-nothing semantics [27]; JSON Merge Patch cannot patch arrays element-wise [28] — so the builder's changelog should be semantic changes derived from patches, not raw patches.
  • Records/cases: Central Entities allow deleting a property only "if its value is not set in any Entities", track baseVersion per update, and hold out-of-order offline updates up to 5 days before marking conflicts [11]. Form.io shows the two display modes worth copying: "Current version mode" vs "Original version mode" for rendering old submissions [23].
  • Recommendation in one line: compat-classified, patch-derived changelogs; drafts migrate only by explicit MigrationPlan; finalized/outbox never migrate; server keeps every published version + SHA-256 hash and quarantines unknown versions; rasd MAJOR.MINOR with must-ignore/preserve for minors and converters for majors; storage migrations are hash-tracked, chunked, resumable and schema-only inside the engine transaction.

1. How incumbents version forms and treat data from older versions

PlatformVersion identityRule to publish an updateType/name change rulesOld-version submissions on serverDevice behaviour on updateExports across versions
ODK Central (2025–26 docs)xmlFormId + free-string version (XForms: "can contain any string value" [10]); MD5 of XML for OpenRosa; versions endpoint lists all published versions [2]Draft → publish; "the version must change" [1]; reused version string → error; ?version= override at publish [2]"Once a field is defined in a published Form version, the field's Data Type cannot be changed", except to text; draft with overlapping path + different type is rejected [1][2]Accepted for any published, non-deleted version [8]; each submission carries formVersion; edits require deprecatedID else 409 [3]; states Open/Closing/Closed; deleted forms → 30-day trash then purge [1]Update modes: Manual / Previously downloaded only / Exactly match server; "Hide old form versions"; deleted blank forms stay hidden-but-openable while filled forms exist [6][7]; drafts open in original version, no migration [9]Export "include previously deleted fields" [5]; OData exposes __system/formVersion [3]
KoboToolboxAsset versions; submissions carry __version__ (formpack default version_id_key) [14]Redeploy after any change; web-form users are prompted to "discard the data already entered or load it into the new version" [12]No hard block; docs list changes to avoid: column rename, type change with same name, moving in/out of groups, removing choices, deleting referenced questions [12]Accepted; formpack maps rows to a version and skips unknown ones [15]KoboCollect: manual or auto-download of new version [12]"Include data from all versions" default on; renamed/deleted questions otherwise disappear [13]; per-version export not available (2021) [16]; a 2024 thread reports oldest-version data vanishing from an "all versions" export with no documented root cause [17]
SurveyCTOInteger version, digit count must not change (10-digit timestamp convention) [18]Upload new definition; "Only new submissions, begun after you updated your form, will benefit" [18]Warns: "be careful about removing fields, renaming fields, or changing groups"; rename = delete + add; groups are part of the field path [18][19]Accepted; older data "may be missing some data" [18]Manual Get Blank Form or hourly auto-download, "won't be installed until the user confirms" [18]"Export missing data" appends every historical field as a column; "ignore groups" merges same-named fields [19]
CommCareApp build numbers, "released"/starred builds; "View Changes" diffs menus/forms/questions between two versions [20]Make New Version → In Test → Released; auto-update Off/On/Forced, delivered at next login [20][21]No block; higher CommCare minor makes resources undownloadable by older clients [21]Case properties persist; deletion of case property values is a separate operationIncomplete forms load in the new form; removed choice can force-close; advice: don't keep forms incomplete long [22]Case Property History; per-app-version comparison [20]
Form.io_vid revision counter; drafts; revision notes; restore = clone as new revision [23]Enable Form Revisions; publish draftnone documentedSubmissions tagged with the revision they were captured inWeb only"Current version mode" (render all data in latest revision) vs "Original version mode" [23]
SurveyJS [unverified this pass]none built-inn/an/aJSON results keyed by question name; unknown keys are kept in survey.data but not shownn/an/a
DHIS2 metadata versioning [unverified — docs page not reachable]Version_N snapshots with hash, ATOMIC vs BEST_EFFORT import (from memory)

Takeaways: (a) version = free string but must be unique and monotonic per client logic; (b) type change on a live name is the only thing anybody hard-blocks; (c) nobody has first-class draft migration; (d) exports are the place where renames hurt most.

2. Schema-evolution theory to borrow

  • Avro schema resolution [24]: fields matched by name; reader-only field must have a default; writer-only field ignored; unknown enum symbol → enum default or error; only widening promotions (int→long→float→double, string↔bytes); aliases for renames. Rasd equivalent: default on new questions, ignore-but-preserve unknown answers, retiredChoices/unmapped policy, a fixed widening lattice, and a rename map.
  • Confluent compatibility levels [25]: BACKWARD = new reader reads old data (add optional / remove fields; upgrade consumers first) — this is the server/export side; FORWARD = old reader reads new data (upgrade producers first) — this is the device side while old clients still run; FULL/_TRANSITIVE check against all previous versions. Rasd should evaluate transitivity because devices can skip several versions.
  • Protobuf [26]: never renumber; removed fields are reserved; unknown fields preserved on round-trip; renames safe for binary but "risky for JSON... since the field name is serialized". Rasd name is the wire key, so renames need an explicit alias, and retired names must be reserved.
  • Kubernetes [34]: removal only on version bump; lossless round-trip between versions; stable APIs live for the whole major; deprecation surfaced by warning header + audit annotation. Rasd: deprecated keyword in the JSON Schema (JSON Schema 2020-12 defines deprecated: "applications SHOULD refrain from usage" [31]), warnings from the validator, removal only at rasd major.
  • Patch formats: RFC 6902 ops add/remove/replace/move/copy/test, atomic; test gives preconditions [27]. Merge Patch cannot express array edits and cannot set null [28] — unusable for question arrays. Immer patches: {op,path:[...],value}, inverse patches, not minimal [30]. jsondiffpatch 0.7.6 (MIT, ESM-only, ~16 KB gz) can produce LCS array diffs with objectHash and emit RFC 6902 [29] — good for computing a semantic diff by question id, whereas immer records the editing session.
  • Migration bookkeeping: Prisma's _prisma_migrations checks "If a migration was run..., If an applied migration was deleted, If an applied migration was changed" and warns on modified applied migrations [47]; Drizzle stores hash + timestamp in __drizzle_migrations [43]. Copy: immutable, hash-checked, ordered steps.

3. Change taxonomy — decision table

Compat classes: C compatible (no data change), T compatible-with-transform (mechanical MigrationPlan step exists, lossless or user-confirmed), B breaking (lossy/ambiguous; requires manual plan or a new form). Columns: (a) draft submissions, (b) finalized/unsent outbox, (c) synced data & exports, (d) records/cases.

#Edit(a) Drafts(b) Finalized/unsent(c) Synced/exports(d) Records/casesNotes / precedent
1Add optional questionCC (stays pinned)C (new column, empty for old rows)C (new property, null)Avro reader-default rule [24]
2Add required questionT (draft must be answered before finalize)C (never re-opened)CT (needs default or stays "incomplete")Kobo: older submissions "may require new answers" [12]
3Remove questionT (drop step; keep as orphaned data)C (pinned)T (export union / "deleted fields")B unless property empty (Central rule [11])Never reuse the name later with another type [2][26]
4Rename nameT with rename step; B withoutCT only if server holds the alias; else = remove+add [18][19][12]T via aliasAvro aliases [24]
5Change type — widening (int→decimal, date→datetime, select_one→text, any→text)T (transform)CT (column type change)TCentral allows only →text [1]
6Change type — narrowing (text→int, select_many→select_one)BCB (Central rejects) [2]BBuilder must block
7Add choiceCCCC
8Remove choiceT if remapChoices/retire else B for drafts holding itCC (old value persists) [12]TCommCare crash case [22]
9Rename choice valueT (remapChoices)CT (needs remap in exports)TAvro enum default [24]
10Change choice label / question label / hint / translationCCC (label mode exports change)CKobo lists label changes across versions as export nuisance [17]
11Change relevance / constraint / required / calculateC data-wise, T behaviour-wise (draft revalidated; may become invalid)C (pinned; edits use old rules like Collect [6])C (analysis caveat: Central warns changing definitions "will likely complicate analysis" [1])T for calculates that feed propertiesRecompute calculates on migrate
12Add/remove/rename group or page (non-repeat)C if name is form-unique and storage keys exclude group path (recommended); T/B otherwiseCC / B (SurveyCTO group-path rule [19])CDecide once: keys are name, not path
13Add repeat (wrap existing scalar)T (wrapRepeat)CTT
14Remove repeat (unwrap)B unless pick: first acceptedCBBlossy
15Move question into/out of repeatBCBBcardinality change
16Appearance / theme hints / widgetCCCC
17Dataset reference change (same dataset, newer version)T (validate values; unknown values follow row 8)CCCsee §10
18Dataset reference change (different dataset)T if value space overlaps else BCTT
19Change idB (new form)n/anew tablenew entity typeCentral: deleted-then-recreated form rejects old submissions [8]
20Change of rasd minorC (must-ignore)CCC§7
21Change of rasd majorT via converterCTT§7

4. Builder policy once a form is published

Compare Central: allow add/remove fields, block type change except →text, block version reuse [1][2]. Proposed Rasd policy:

  • Allow silently: rows 1, 7, 10, 12 (with name-based keys), 16, 20; new translations; ordering; pages.
  • Warn (requires acknowledging + auto-generated changelog entry): 2, 3, 4 (offer rename instead of delete+add — detect remove+add of same type in the same session and prompt "Was hh_size renamed to household_size?"), 8, 9, 11 (show impacted drafts count if devices report), 13, 17.
  • Block (publish disabled until fixed or a new id is created): 6, 14, 15, 18 without remap, 19, reuse of a reserved (previously removed) name with a different type, publish with an already-used version, publish with unchanged definitionHash.
  • Version string: builder auto-suggests next version; enforce monotonic compare (localeCompare with numeric:true on dot-separated parts) and uniqueness per id; store revision (server integer) alongside — SurveyCTO's "digit count must stay the same" [18] is a symptom of string comparison; avoid it.

Changelog from patch history: keep raw immer patches (produceWithPatches, inverse patches for undo [30]) per editing session, but persist a semantic changelog computed at publish time by diffing fromDef vs toDef by question id (jsondiffpatch with objectHash: q => q.id [29]) → SemanticChange[] = {kind, target, from, to, compat: 'C'|'T'|'B', suggestedStep?}. Session patches are used only to disambiguate renames vs remove/add (a replace on /questions/3/name is a rename; a remove + add is not). Store changelog, migrationPlan (auto-seeded from T rows) and definitionHash in the version record; show as a "View changes" diff like CommCare [20].

5. Lifecycle and device state diagrams

Form lifecycle (server):

stateDiagram-v2
[*] --> Draft
Draft --> Draft: edit / test submissions (purged on publish)
Draft --> Published: publish (version unique, hash new, no B changes)
Published --> Draft: new draft (copy of latest)
Published --> Closing: closing (no download, still accepts)
Closing --> Closed: close (rejects submissions)
Published --> Retired: superseded by newer version (still accepts, not distributed)
Retired --> Purged: after retention window AND no device references AND admin action
Closed --> Trash: delete (30-day trash like Central)
Trash --> Published: restore
Trash --> Purged

Device handling of an incoming version:

stateDiagram-v2
[*] --> Check: sync pulls manifest {id, version, hash, rasd, plan?}
Check --> Refuse: rasd major unsupported or requires > client
Check --> Download: hash unknown
Download --> Verify: sha256 == manifest.hash
Verify --> Installed: store def; new submissions use it
Installed --> DraftsPending: drafts pinned to older version?
DraftsPending --> AutoMigrate: plan.mode == auto AND loss == none AND user setting on
DraftsPending --> Prompt: plan.mode == assisted
DraftsPending --> KeepOld: no plan or B change (draft stays on old version, old def retained)
AutoMigrate --> Installed
Prompt --> Installed
Installed --> GC: outbox empty for old version AND no drafts AND older than N kept

Retention on device: keep any version referenced by a draft or outbox item (mandatory), plus the last 3 versions of each form (configurable), plus every version's definitionHash. Definitions are small (tens–hundreds of KB) relative to media, so retention cost is negligible; the real cost is rendering many versions of choice lists — share datasets by hash. Follow Collect's UX: only the newest version is offered for new submissions ("Hide old form versions" [7]) and a deleted/retired blank form stays "hidden from Start new form but... available... so that the remaining filled forms can be opened" [6].

6. Server behaviour

  1. Accept a submission iff (formId, version) was ever published and the form is not Closed/Trash — Central's rule [8]. Retired versions keep accepting until purge (outbox items "sit for weeks").
  2. Persist per submission: formVersion, definitionHash, rasd, clientVersion, and the raw payload bytes; verify definitionHash matches the stored definition for that version, else flag hashMismatch (tampering or a client with a stale draft of the same version string).
  3. Unknown version or unknown hash → do not drop: 422 with quarantine=true, store in a quarantine table with reason; expose in admin UI. This is the fix for formpack's silent skip [15] and for Central's rejection of recreated forms [8].
  4. Exports: union of columns across versions (Central "deleted fields" [5], Kobo "all versions" [13]), always emit __version and __definitionHash columns, apply the server-side alias graph (rename map) so renamed columns merge, and offer "latest schema only" and "single version" export (a Kobo gap [16]).
  5. Editing a submission after sync uses the definition version it was captured with (Collect rule [6]; Form.io "Original version mode" [23]); a new submission version is created with deprecatedID-style linkage and 409 on stale base [3].
  6. Retention/purge: purge a version only when no submission references it or after the configured audit window (M&E requirement: reproducibility against the exact definition — the hash + immutable definition store satisfies this).

7. rasd spec-version negotiation

  • rasd: "MAJOR.MINOR" follows SemVer meaning [33]: MINOR = additive (new optional properties, new question types, new expression functions); MAJOR = anything that changes meaning of existing properties or removes them.
  • Client rules for a newer MINOR: must-ignore-and-preserve unknown properties (round-trip them untouched, like proto3 unknown fields [26] and K8s round-trip rule [34]); unknown question type → render a Unsupported placeholder and mark the form degraded unless the definition declares requires: {"rasd": ">=1.3", "features": ["geoshape"]}, in which case refuse to open and report unsupported_feature. Unknown expression function → refuse (silent wrong logic is worse than refusal).
  • Newer MAJOR: refuse unless a bundled converter rfd@2 → rfd@1 exists (must be lossless via an x-legacy extension bag).
  • Server: publishes supportedRasd: ["1.0","1.1"]; a device sends Accept-Rasd; the server may downgrade a definition through converters when a lower minor is requested only for MINOR-additive properties (strip), never for requires.
  • Publishing schemas & types: JSON Schema per version with absolute $id (https://schemas.rasd.dev/rfd/1.1/rfd.schema.json), $schema 2020-12 [32]; deprecated: true + $comment on retired keywords [31]; npm @rasd/spec exports RFD_1_1 types plus type RFD = RFD_1_0 | RFD_1_1 and isRfd11() guards; additionalProperties allowed at extension points (x-*) so minor bumps validate on old validators.
  • Deprecation policy (K8s-derived [34]): a property deprecated in 1.x stays parseable for the whole 1.x line and ≥12 months; removal only in 2.0; validators emit warnings (not errors) for deprecated usage; every 2.0 converter must round-trip 1.x definitions.

8. Proposed MigrationPlan (input to migrateSubmission(sub, fromDef, toDef, plan?))

{
"rasd": "1.0",
"formId": "unrwa_shelter_assessment",
"from": "2026.3", "to": "2026.4",
"fromHash": "sha256:9f…", "toHash": "sha256:1c…",
"mode": "auto", // auto | assisted | manual
"loss": "none", // none | possible | certain (computed)
"steps": [
{ "op": "test", "path": "hh_size", "type": "integer" },
{ "op": "rename", "from": "hh_size", "to": "household_size" },
{ "op": "transform", "target": "age", "expr": "number(${age_text})",
"onError": "null" }, // null | keep | fail
{ "op": "default", "target": "consent_recorded", "value": false,
"when": "${consent_recorded} = ''" },
{ "op": "remapChoices", "target": "district",
"map": { "D01": "DIST_01", "D02": "DIST_02" }, "unmapped": "keep" }, // keep | drop | fail
{ "op": "drop", "target": "old_note", "keep": "orphan" }, // orphan | discard
{ "op": "wrapRepeat", "target": "child_name", "into": "children", "as": "name" },
{ "op": "unwrapRepeat", "target": "children", "pick": "first", "as": "child_name" },
{ "op": "recalculate", "targets": ["*"] }
],
"review": { "requiresUser": false, "message": { "en": "…", "ar": "…" } }
}

Semantics: steps run in order, atomically (RFC 6902 style: any fail aborts and the draft stays on fromDef) [27]; test is a precondition; expr uses the RFD expression language evaluated against the pre-step answers; result = { data, warnings[], orphaned: {name: value}, changedPaths[], to, toHash }; migrateSubmission refuses when sub.status !== 'draft' unless {force:true} (finalized/outbox items are attestations); plan omitted ⇒ derive from diffDefinitions(fromDef,toDef) and refuse if any B change lacks a step. Plans compose transitively (2026.2→2026.4 = concat) but the builder should ship a direct plan for the last 3 versions.

9. Storage-adapter Migration interface

interface Migration {
id: string; // "0007_outbox_add_definition_hash" — ordered, immutable
target: number; // Dexie version(n) / SQLite PRAGMA user_version after step
checksum: string; // sha256 of the step's source; mismatch => refuse to run
kind: 'schema' | 'data';
up(ctx: MigrationContext): Promise<void>;
down?(ctx: MigrationContext): Promise<void>;
estimate?(ctx): Promise<{ rows: number }>; // for progress + "back up first?" prompt
chunk?: { size: number; cursorKey: string }; // data steps only
}
interface MigrationContext {
engine: 'dexie' | 'expo-sqlite' | 'op-sqlite';
tx: unknown; // engine transaction (schema steps)
progress(done: number, total: number): void;
checkpoint(cursor: string): Promise<void>; // persisted in _rasd_migrations
signal: AbortSignal; log: Logger; keys?: KeyProvider; // for encrypted rows
}

Rules distilled from the sources:

  • Schema steps run inside the engine's upgrade transaction: Dexie version(n).stores().upgrade() (sequential, rolls back on error [35]); SQLite BEGIN … COMMIT with PRAGMA user_version = n last [44][46]; complex table changes use SQLite's 12-step rewrite (foreign_keys=OFF, create new, copy, drop, rename, foreign_key_check, commit) [45]. Never touch schema_version [46].
  • Data steps (re-encoding tens of thousands of submissions) run after the schema upgrade, in chunks (e.g., 500 rows), each chunk in its own transaction with a persisted cursor in _rasd_migrations (id, checksum, state, cursor, applied_at) so a crash on a low-end Android resumes instead of restarting; steps must be idempotent (WHERE schema_rev < n). No primary benchmark was found for IndexedDB bulk-rewrite cost on low-end devices [unverified] — measure in the test matrix.
  • Dexie specifics: keep every version with an upgrader "as long as there are code out there" on lower versions [35]; declare only changed tables; null deletes a table [36]; on versionchange close and prompt reload — Dexie's default is to close and console.warn [37]; on blocked show "close other tabs" UI [38]. Dexie 4.4.5 (Apache-2.0) per npm [41].
  • SQLite specifics: expo-sqlite 57.0.1 [49] documents PRAGMA user_version in onInit, withExclusiveTransactionAsync (only in-scope queries join the tx), WAL, backupDatabaseAsync, serializeAsync [44]; op-sqlite 18.0.0 (MIT) [50]; Drizzle 0.45.2 [48] with driver:'expo', useMigrations, __drizzle_migrations hash table [42][43] — Rasd's Migration[] should be able to wrap Drizzle-generated SQL as kind:'schema' steps.
  • Backup/rollback: before a data step with estimate.rows > 5 000 and enough free space, take backupDatabaseAsync (native) or Dexie export [export addon unverified]; down is optional and only for schema; the real rollback is "restore backup + old app build".
  • Encrypted rows: run re-encryption as a data step that requires ctx.keys (deferred until the user unlocks) rather than inside the upgrade transaction; dexie-encrypted 2.0.0 exists as a Dexie middleware [51] but its key-change behaviour was not verified [unverified].
  • Multi-tab / multi-process: web — single upgrader elected via versionchange; native — run migrations before opening the app shell; both — applied rows are checked by checksum like Prisma/Drizzle [47][43].

10. Datasets and themes referenced by forms

  • Datasets (choice lists, admin boundaries) are versioned separately: { id, version, hash, schema:{keyField, labelFields} }. A form references {"dataset":"admin2","minVersion":"2026-06"}; drafts record the dataset version they used only for validation provenance, not pinning — a newer dataset is always allowed on device (Collect/Central treat entity lists as live [11]), and a value that disappears is handled by taxonomy row 8 (retire, keep value + label snapshot in the answer: {value, label}), so exports remain reproducible.
  • Server keeps every dataset version by hash; devices keep the versions referenced by installed form versions; GC with the form GC.
  • Themes are non-data: { id, version, rasdTheme:"1.0" }; any theme version may render any form version; theme spec version follows the same MAJOR.MINOR must-ignore rules; never a migration concern.
  • Records/cases: property schema is append-only while values exist (Central rule [11]); each update carries baseVersion; server applies in order, marks parallel/conflict, and holds out-of-order offline updates for a window (Central: 5 days [11]) — copy these semantics.

11. Test matrix

AxisCases
Change kinds (rows 1–21 above)each × {draft, finalized-unsent, synced, record} × {web/Dexie, native/SQLite} — assert compat class, MigrationPlan auto-seed, loss value, and that finalized items are untouched
Version skewdevice on v1 receives v3 (transitive plan), v2 skipped; two devices publish edits to the same record from v1 and v3
Refusalsunknown rasd minor with/without requires; unknown major; unknown question type; unknown function
Server ingestold published version, retired version, closed form, unknown version (quarantine), hash mismatch, recreated form id
Exportsunion columns; alias merge; single-version export; RTL/Arabic labels per version
DB migrationsfresh install → latest; v(n-3) → latest; crash mid-chunk then resume; checksum tampering; multi-tab blocked; encrypted rows without key; low free space (backup skipped, warn); 50k submissions on a 2 GB-RAM Android — record wall time
Builderrename detection from patches; blocked publish on narrowing type; version reuse; hash unchanged; changelog rendering
Datasets/themesdataset update removes a used value; theme major bump; dataset referenced by two form versions

Implications & recommendations for Rasd Forms

  1. Make name form-unique and the storage key; never encode group/page path in keys — this turns the most common breaking change at ODK/SurveyCTO/Kobo (group moves) into a cosmetic one.
  2. Adopt Central's two hard blocks (no type change except widening→text; no version reuse) and add: no reuse of a retired name with a different type (Protobuf reserved).
  3. Ship diffDefinitions(from,to) → {changes, plan, loss} in core; the builder persists a semantic changelog + definitionHash per version and offers a CommCare-style diff view.
  4. Draft migration is opt-in and plan-driven; finalized/outbox items are never migrated (they are the enumerator's attestation) — this matches Collect and avoids CommCare's force-close class of bugs.
  5. Server keeps every published version immutable, stores definitionHash per submission, accepts any published version until purge, and quarantines unknown versions/hashes rather than dropping them.
  6. Exports default to union-of-versions with __version + __definitionHash columns and alias merging; add "single version" and "latest schema" modes.
  7. rasd MAJOR.MINOR: must-ignore-and-preserve for minors, requires for hard features, converters for majors, ≥12-month deprecation, JSON Schema $id per version and versioned TS types.
  8. Storage Migration[]: hash-checked, ordered, schema steps in the engine transaction, data steps chunked and resumable via _rasd_migrations, backup before large data steps, explicit multi-tab handling.
  9. Datasets versioned by hash and always upgradable; answers snapshot {value,label} for dataset-driven choices; themes never migrate.
  10. Records use append-only property schemas while values exist, baseVersion per update, and an out-of-order hold window.

Unverified / conflicting items

  • DHIS2 metadata versioning details (Version_N, ATOMIC/BEST_EFFORT) — docs pages returned 404 in this pass; SurveyJS behaviour stated from memory; IndexedDB bulk-migration cost on low-end Android — no primary benchmark; dexie-encrypted key-rotation semantics; Dexie 4 changes to version-declaration ergonomics (docs fetched describe the ≥3.0 rule only). Kobo thread [17] reports missing oldest-version data with no documented root cause. npm "latest" numbers reflect the registry on 2026-08-15 and may include pre-releases.

Sources (accessed 2026-08-15)

  1. ODK Central docs — Managing Forms: https://docs.getodk.org/central-forms/
  2. ODK Central API — Form Management: https://docs.getodk.org/central-api-form-management/
  3. ODK Central API — Submission Management: https://docs.getodk.org/central-api-submission-management/
  4. ODK Central API — OData Endpoints: https://docs.getodk.org/central-api-odata-endpoints/
  5. ODK Central docs — Managing Submissions: https://docs.getodk.org/central-submissions/
  6. ODK Collect docs — Blank Forms / form updates: https://docs.getodk.org/collect-forms/
  7. ODK Collect docs — Settings (Form management): https://docs.getodk.org/collect-settings/
  8. ODK Forum — "Can you still send data from old versions of forms to ODK central" (Hélène Martin, 2021-04-20): https://forum.getodk.org/t/can-you-still-send-data-from-old-versions-of-forms-to-odk-central/33257
  9. ODK Forum — "Open a draft with an updated version of the form" (2025-03-24): https://forum.getodk.org/t/open-a-draft-with-an-updated-version-of-the-form/54287
  10. ODK XForms Specification: https://getodk.github.io/xforms-spec/
  11. ODK Central docs — Entities: https://docs.getodk.org/central-entities/
  12. KoboToolbox — Deploying and redeploying forms: https://support.kobotoolbox.org/deploy_form_new_project.html
  13. KoboToolbox — Advanced export options: https://support.kobotoolbox.org/advanced_export.html
  14. kobotoolbox/formpack pack.py (default_version_id_key='__version__'): https://github.com/kobotoolbox/formpack/blob/master/src/formpack/pack.py
  15. kobotoolbox/formpack reporting/export.py (unknown-version skip TODO): https://github.com/kobotoolbox/formpack/blob/master/src/formpack/reporting/export.py
  16. Kobo Community — "Export (download) data to a specific form version" (2021): https://community.kobotoolbox.org/t/19234
  17. Kobo Community — "Include all versions is ticked... data from the oldest version is not in the XLS export" (2024): https://community.kobotoolbox.org/t/51045
  18. SurveyCTO docs — Updating an existing form: https://docs.surveycto.com/02-designing-forms/01-core-concepts/10.updating.html
  19. SurveyCTO docs — Missing data from old form versions: https://docs.surveycto.com/02-designing-forms/01-core-concepts/11.missing-data.html
  20. CommCare — Manage Application Versions: https://dimagi.atlassian.net/wiki/spaces/commcarepublic/pages/2143957917/Manage+Application+Versions
  21. CommCare — Update Your Application: https://dimagi.atlassian.net/wiki/spaces/commcarepublic/pages/2143957871/Update+Your+Application
  22. CommCare — Force Close from Loading Incomplete Forms: https://dimagi.atlassian.net/wiki/spaces/commcarepublic/pages/2143947740/CommCare+for+Android+Force+Close+from+Loading+Incomplete+Forms
  23. Form.io — Form Revisions: https://help.form.io/form-building/form-revisions
  24. Apache Avro 1.12.0 Specification — Schema Resolution: https://avro.apache.org/docs/1.12.0/specification/
  25. Confluent Schema Registry — Schema Evolution and Compatibility: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
  26. Protocol Buffers — Language Guide (proto3), Updating a Message Type: https://protobuf.dev/programming-guides/proto3/
  27. RFC 6902 JSON Patch: https://www.rfc-editor.org/rfc/rfc6902
  28. RFC 7386 JSON Merge Patch: https://www.rfc-editor.org/rfc/rfc7386
  29. jsondiffpatch README (0.7.6, MIT): https://github.com/benjamine/jsondiffpatch and https://registry.npmjs.org/jsondiffpatch/latest
  30. Immer — Patches: https://immerjs.github.io/immer/patches
  31. JSON Schema 2020-12 Validation (meta-data vocabulary, deprecated): https://json-schema.org/draft/2020-12/json-schema-validation
  32. JSON Schema — Structuring a complex schema ($id): https://json-schema.org/understanding-json-schema/structuring
  33. Semantic Versioning 2.0.0: https://semver.org/
  34. Kubernetes — API Deprecation Policy: https://kubernetes.io/docs/reference/using-api/deprecation-policy/
  35. Dexie — Design tutorial, Database Versioning: https://dexie.org/docs/Tutorial/Design
  36. Dexie — Understanding the basics: https://dexie.org/docs/Tutorial/Understanding-the-basics
  37. Dexie — Dexie.on.versionchange: https://dexie.org/docs/Dexie/Dexie.on.versionchange
  38. Dexie — Dexie.on.blocked: https://dexie.org/docs/Dexie/Dexie.on.blocked
  39. Dexie — Version.upgrade(): https://dexie.org/docs/Version/Version.upgrade()
  40. Dexie — Version.stores(): https://dexie.org/docs/Version/Version.stores()
  41. npm registry — dexie latest (4.4.5, Apache-2.0): https://registry.npmjs.org/dexie/latest
  42. Drizzle ORM — Expo SQLite: https://orm.drizzle.team/docs/connect-expo-sqlite
  43. Drizzle ORM — Migrations: https://orm.drizzle.team/docs/migrations
  44. Expo SQLite docs: https://docs.expo.dev/versions/latest/sdk/sqlite/
  45. SQLite — ALTER TABLE: https://www.sqlite.org/lang_altertable.html
  46. SQLite — PRAGMA statements: https://www.sqlite.org/pragma.html
  47. Prisma — Migration histories: https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/migration-histories
  48. npm registry — drizzle-orm latest (0.45.2): https://registry.npmjs.org/drizzle-orm/latest
  49. npm registry — expo-sqlite latest (57.0.1, MIT): https://registry.npmjs.org/expo-sqlite/latest
  50. npm registry — @op-engineering/op-sqlite latest (18.0.0, MIT): https://registry.npmjs.org/@op-engineering/op-sqlite/latest
  51. npm registry — dexie-encrypted latest (2.0.0, MIT): https://registry.npmjs.org/dexie-encrypted/latest