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
rasdspec 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) andblocked[37][38]. Expo SQLite's documented pattern isPRAGMA user_versioninSQLiteProvider.onInit,withExclusiveTransactionAsync, andbackupDatabaseAsync[44]; Drizzle tracks applied SQL migrations in__drizzle_migrationsby 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/testand 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
baseVersionper 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;rasdMAJOR.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
| Platform | Version identity | Rule to publish an update | Type/name change rules | Old-version submissions on server | Device behaviour on update | Exports 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] |
| KoboToolbox | Asset 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] |
| SurveyCTO | Integer 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] |
| CommCare | App 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 operation | Incomplete 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 draft | none documented | Submissions tagged with the revision they were captured in | Web only | "Current version mode" (render all data in latest revision) vs "Original version mode" [23] |
| SurveyJS [unverified this pass] | none built-in | n/a | n/a | JSON results keyed by question name; unknown keys are kept in survey.data but not shown | n/a | n/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:
defaulton new questions, ignore-but-preserve unknown answers,retiredChoices/unmappedpolicy, 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/
_TRANSITIVEcheck 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". Rasdnameis 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:
deprecatedkeyword in the JSON Schema (JSON Schema 2020-12 definesdeprecated: "applications SHOULD refrain from usage" [31]), warnings from the validator, removal only atrasdmajor. - Patch formats: RFC 6902 ops add/remove/replace/move/copy/test, atomic;
testgives 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 withobjectHashand emit RFC 6902 [29] — good for computing a semantic diff by questionid, whereas immer records the editing session. - Migration bookkeeping: Prisma's
_prisma_migrationschecks "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/cases | Notes / precedent |
|---|---|---|---|---|---|---|
| 1 | Add optional question | C | C (stays pinned) | C (new column, empty for old rows) | C (new property, null) | Avro reader-default rule [24] |
| 2 | Add required question | T (draft must be answered before finalize) | C (never re-opened) | C | T (needs default or stays "incomplete") | Kobo: older submissions "may require new answers" [12] |
| 3 | Remove question | T (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] |
| 4 | Rename name | T with rename step; B without | C | T only if server holds the alias; else = remove+add [18][19][12] | T via alias | Avro aliases [24] |
| 5 | Change type — widening (int→decimal, date→datetime, select_one→text, any→text) | T (transform) | C | T (column type change) | T | Central allows only →text [1] |
| 6 | Change type — narrowing (text→int, select_many→select_one) | B | C | B (Central rejects) [2] | B | Builder must block |
| 7 | Add choice | C | C | C | C | |
| 8 | Remove choice | T if remapChoices/retire else B for drafts holding it | C | C (old value persists) [12] | T | CommCare crash case [22] |
| 9 | Rename choice value | T (remapChoices) | C | T (needs remap in exports) | T | Avro enum default [24] |
| 10 | Change choice label / question label / hint / translation | C | C | C (label mode exports change) | C | Kobo lists label changes across versions as export nuisance [17] |
| 11 | Change relevance / constraint / required / calculate | C 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 properties | Recompute calculates on migrate |
| 12 | Add/remove/rename group or page (non-repeat) | C if name is form-unique and storage keys exclude group path (recommended); T/B otherwise | C | C / B (SurveyCTO group-path rule [19]) | C | Decide once: keys are name, not path |
| 13 | Add repeat (wrap existing scalar) | T (wrapRepeat) | C | T | T | |
| 14 | Remove repeat (unwrap) | B unless pick: first accepted | C | B | B | lossy |
| 15 | Move question into/out of repeat | B | C | B | B | cardinality change |
| 16 | Appearance / theme hints / widget | C | C | C | C | |
| 17 | Dataset reference change (same dataset, newer version) | T (validate values; unknown values follow row 8) | C | C | C | see §10 |
| 18 | Dataset reference change (different dataset) | T if value space overlaps else B | C | T | T | |
| 19 | Change id | B (new form) | n/a | new table | new entity type | Central: deleted-then-recreated form rejects old submissions [8] |
| 20 | Change of rasd minor | C (must-ignore) | C | C | C | §7 |
| 21 | Change of rasd major | T via converter | C | T | T | §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_sizerenamed tohousehold_size?"), 8, 9, 11 (show impacted drafts count if devices report), 13, 17. - Block (publish disabled until fixed or a new
idis created): 6, 14, 15, 18 without remap, 19, reuse of a reserved (previously removed)namewith a different type, publish with an already-usedversion, publish with unchangeddefinitionHash. - Version string: builder auto-suggests next version; enforce monotonic compare (
localeComparewithnumeric:trueon dot-separated parts) and uniqueness perid; storerevision(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
- 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"). - Persist per submission:
formVersion,definitionHash,rasd,clientVersion, and the raw payload bytes; verifydefinitionHashmatches the stored definition for that version, else flaghashMismatch(tampering or a client with a stale draft of the same version string). - Unknown version or unknown hash → do not drop:
422withquarantine=true, store in aquarantinetable 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]. - Exports: union of columns across versions (Central "deleted fields" [5], Kobo "all versions" [13]), always emit
__versionand__definitionHashcolumns, 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]). - 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]. - 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 aUnsupportedplaceholder and mark the formdegradedunless the definition declaresrequires: {"rasd": ">=1.3", "features": ["geoshape"]}, in which case refuse to open and reportunsupported_feature. Unknown expression function → refuse (silent wrong logic is worse than refusal). - Newer MAJOR: refuse unless a bundled converter
rfd@2 → rfd@1exists (must be lossless via anx-legacyextension bag). - Server: publishes
supportedRasd: ["1.0","1.1"]; a device sendsAccept-Rasd; the server may downgrade a definition through converters when a lower minor is requested only for MINOR-additive properties (strip), never forrequires. - Publishing schemas & types: JSON Schema per version with absolute
$id(https://schemas.rasd.dev/rfd/1.1/rfd.schema.json),$schema2020-12 [32];deprecated: true+$commenton retired keywords [31]; npm@rasd/specexportsRFD_1_1types plustype RFD = RFD_1_0 | RFD_1_1andisRfd11()guards;additionalPropertiesallowed 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]); SQLiteBEGIN … COMMITwithPRAGMA user_version = nlast [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 touchschema_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;
nulldeletes a table [36]; onversionchangeclose and prompt reload — Dexie's default is to close andconsole.warn[37]; onblockedshow "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_versioninonInit,withExclusiveTransactionAsync(only in-scope queries join the tx), WAL,backupDatabaseAsync,serializeAsync[44]; op-sqlite 18.0.0 (MIT) [50]; Drizzle 0.45.2 [48] withdriver:'expo',useMigrations,__drizzle_migrationshash table [42][43] — Rasd'sMigration[]should be able to wrap Drizzle-generated SQL askind:'schema'steps. - Backup/rollback: before a
datastep withestimate.rows > 5 000and enough free space, takebackupDatabaseAsync(native) or Dexie export [export addon unverified];downis optional and only forschema; the real rollback is "restore backup + old app build". - Encrypted rows: run re-encryption as a
datastep that requiresctx.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 —appliedrows 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
| Axis | Cases |
|---|---|
| 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 skew | device on v1 receives v3 (transitive plan), v2 skipped; two devices publish edits to the same record from v1 and v3 |
| Refusals | unknown rasd minor with/without requires; unknown major; unknown question type; unknown function |
| Server ingest | old published version, retired version, closed form, unknown version (quarantine), hash mismatch, recreated form id |
| Exports | union columns; alias merge; single-version export; RTL/Arabic labels per version |
| DB migrations | fresh 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 |
| Builder | rename detection from patches; blocked publish on narrowing type; version reuse; hash unchanged; changelog rendering |
| Datasets/themes | dataset update removes a used value; theme major bump; dataset referenced by two form versions |
Implications & recommendations for Rasd Forms
- Make
nameform-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. - Adopt Central's two hard blocks (no type change except widening→text; no version reuse) and add: no reuse of a retired
namewith a different type (Protobufreserved). - Ship
diffDefinitions(from,to) → {changes, plan, loss}in core; the builder persists a semantic changelog +definitionHashper version and offers a CommCare-style diff view. - 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.
- Server keeps every published version immutable, stores
definitionHashper submission, accepts any published version until purge, and quarantines unknown versions/hashes rather than dropping them. - Exports default to union-of-versions with
__version+__definitionHashcolumns and alias merging; add "single version" and "latest schema" modes. rasdMAJOR.MINOR: must-ignore-and-preserve for minors,requiresfor hard features, converters for majors, ≥12-month deprecation, JSON Schema$idper version and versioned TS types.- Storage
Migration[]: hash-checked, ordered,schemasteps in the engine transaction,datasteps chunked and resumable via_rasd_migrations, backup before large data steps, explicit multi-tab handling. - Datasets versioned by hash and always upgradable; answers snapshot
{value,label}for dataset-driven choices; themes never migrate. - Records use append-only property schemas while values exist,
baseVersionper 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)
- ODK Central docs — Managing Forms: https://docs.getodk.org/central-forms/
- ODK Central API — Form Management: https://docs.getodk.org/central-api-form-management/
- ODK Central API — Submission Management: https://docs.getodk.org/central-api-submission-management/
- ODK Central API — OData Endpoints: https://docs.getodk.org/central-api-odata-endpoints/
- ODK Central docs — Managing Submissions: https://docs.getodk.org/central-submissions/
- ODK Collect docs — Blank Forms / form updates: https://docs.getodk.org/collect-forms/
- ODK Collect docs — Settings (Form management): https://docs.getodk.org/collect-settings/
- 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
- 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
- ODK XForms Specification: https://getodk.github.io/xforms-spec/
- ODK Central docs — Entities: https://docs.getodk.org/central-entities/
- KoboToolbox — Deploying and redeploying forms: https://support.kobotoolbox.org/deploy_form_new_project.html
- KoboToolbox — Advanced export options: https://support.kobotoolbox.org/advanced_export.html
- kobotoolbox/formpack
pack.py(default_version_id_key='__version__'): https://github.com/kobotoolbox/formpack/blob/master/src/formpack/pack.py - kobotoolbox/formpack
reporting/export.py(unknown-version skip TODO): https://github.com/kobotoolbox/formpack/blob/master/src/formpack/reporting/export.py - Kobo Community — "Export (download) data to a specific form version" (2021): https://community.kobotoolbox.org/t/19234
- 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
- SurveyCTO docs — Updating an existing form: https://docs.surveycto.com/02-designing-forms/01-core-concepts/10.updating.html
- SurveyCTO docs — Missing data from old form versions: https://docs.surveycto.com/02-designing-forms/01-core-concepts/11.missing-data.html
- CommCare — Manage Application Versions: https://dimagi.atlassian.net/wiki/spaces/commcarepublic/pages/2143957917/Manage+Application+Versions
- CommCare — Update Your Application: https://dimagi.atlassian.net/wiki/spaces/commcarepublic/pages/2143957871/Update+Your+Application
- 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
- Form.io — Form Revisions: https://help.form.io/form-building/form-revisions
- Apache Avro 1.12.0 Specification — Schema Resolution: https://avro.apache.org/docs/1.12.0/specification/
- Confluent Schema Registry — Schema Evolution and Compatibility: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
- Protocol Buffers — Language Guide (proto3), Updating a Message Type: https://protobuf.dev/programming-guides/proto3/
- RFC 6902 JSON Patch: https://www.rfc-editor.org/rfc/rfc6902
- RFC 7386 JSON Merge Patch: https://www.rfc-editor.org/rfc/rfc7386
- jsondiffpatch README (0.7.6, MIT): https://github.com/benjamine/jsondiffpatch and https://registry.npmjs.org/jsondiffpatch/latest
- Immer — Patches: https://immerjs.github.io/immer/patches
- JSON Schema 2020-12 Validation (meta-data vocabulary,
deprecated): https://json-schema.org/draft/2020-12/json-schema-validation - JSON Schema — Structuring a complex schema (
$id): https://json-schema.org/understanding-json-schema/structuring - Semantic Versioning 2.0.0: https://semver.org/
- Kubernetes — API Deprecation Policy: https://kubernetes.io/docs/reference/using-api/deprecation-policy/
- Dexie — Design tutorial, Database Versioning: https://dexie.org/docs/Tutorial/Design
- Dexie — Understanding the basics: https://dexie.org/docs/Tutorial/Understanding-the-basics
- Dexie —
Dexie.on.versionchange: https://dexie.org/docs/Dexie/Dexie.on.versionchange - Dexie —
Dexie.on.blocked: https://dexie.org/docs/Dexie/Dexie.on.blocked - Dexie —
Version.upgrade(): https://dexie.org/docs/Version/Version.upgrade() - Dexie —
Version.stores(): https://dexie.org/docs/Version/Version.stores() - npm registry — dexie latest (4.4.5, Apache-2.0): https://registry.npmjs.org/dexie/latest
- Drizzle ORM — Expo SQLite: https://orm.drizzle.team/docs/connect-expo-sqlite
- Drizzle ORM — Migrations: https://orm.drizzle.team/docs/migrations
- Expo SQLite docs: https://docs.expo.dev/versions/latest/sdk/sqlite/
- SQLite — ALTER TABLE: https://www.sqlite.org/lang_altertable.html
- SQLite — PRAGMA statements: https://www.sqlite.org/pragma.html
- Prisma — Migration histories: https://www.prisma.io/docs/orm/prisma-migrate/understanding-prisma-migrate/migration-histories
- npm registry — drizzle-orm latest (0.45.2): https://registry.npmjs.org/drizzle-orm/latest
- npm registry — expo-sqlite latest (57.0.1, MIT): https://registry.npmjs.org/expo-sqlite/latest
- npm registry — @op-engineering/op-sqlite latest (18.0.0, MIT): https://registry.npmjs.org/@op-engineering/op-sqlite/latest
- npm registry — dexie-encrypted latest (2.0.0, MIT): https://registry.npmjs.org/dexie-encrypted/latest