Data Model
Current Data Model
Summary
The current model is centered on:
ProjectSiteFormSubmission
with workflow state attached to submissions.
Submission Identity
Current submission identifiers serve different purposes:
va_sid- canonical local technical submission id
- built as
{KEY}-{form_id.lower()} KEYis the stable ODK submission key (__idin OData)
- displayed
VA Form ID- business-facing identifier shown in the UI
- currently rendered from
va_uniqueid
instanceID- stored as ODK metadata
- not the canonical local sync identity
Current rule:
- DigitVA identity and payload lineage are anchored on ODK
KEY instanceIDmay change across edited versions and must not be treated as the primary local identifier
This is currently implemented as a single-project-first schema.
Core Master Tables
va_research_projects
Purpose:
- stores app project metadata
Key fields:
project_idproject_codeproject_nameproject_nicknameproject_statusnarrative_qa_enabledsocial_autopsy_enabledreviewer_social_autopsy_enabledcoding_intake_modedemo_training_enableddemo_retention_minutesrecode_window_minutes(1..10080, default 1440; TRN01 60): the coder recode / reviewer-eligibility window, migrationc8e4a2f6b1d3(docs/policy/coding-workflow-state-machine.md, "Recode Window")project_country_iso3(nullable, SmartVA's list): stamped on each newva_submissions.va_country_iso3and used by SmartVA ahead ofva_forms.form_smartvacountry, migrationc3f9a1d7e2b4(docs/policy/va-form-project-configuration.md, "Project country")masked_cod_required(default true)cod_entry_mode(simpleby default, ordoriswhen unmasked and ICD-11)
Current role:
- top-level project master, but effectively used in a one-project deployment model
- also stores project-level workflow toggles such as Narrative QA enablement, coder/reviewer Social Autopsy analysis enablement, and coder intake mode
- now also stores demo/training project behavior:
- whether the project is an open training pool
- how many minutes demo-created coding artifacts should remain active before cleanup
The final coder and reviewer assessment tables also hold the unmasked-mode immediate COD and provenance, associated-condition text, and final DORIS certificate plus independent DORIS/CoDEdit result envelopes. Their mode snapshot records masking, entry mode, ICD release and pinned WHO image digest. The existing conclusive COD field remains the human final UCOD and final authority source. See DORIS COD Workflow.
va_sites
Purpose:
- stores site metadata
Key fields:
site_idproject_idsite_namesite_abbrsite_status
Current behavior:
- each site belongs to exactly one project because
project_idis stored directly on the site row
Current limitation:
- sites are not modeled as reusable across multiple projects
va_forms
Purpose:
- stores app form identity and ODK mapping
Key fields:
form_idproject_idsite_idodk_form_idodk_project_idform_type- SmartVA-related flags and country settings
form_status
Current behavior:
- this table combines:
- app-side business identity
- project/site assignment
- standardized form meaning
- ODK source identity
Current convention:
form_idis a synthetic internal key such asUNSW01NC0101- it effectively encodes project, site, and a sequence/version
Current runtime role:
va_formsis still the compatibility registry used by submissions, media paths, and permission queries- active sync scope now comes from
map_project_site_odk, and sync materializes or updates matchingva_formsrows as needed
COD Reporting Master Tables
mas_cod_bucket_schemes
Purpose:
- stores versioned COD reporting schemes such as
SRS IndiaandCMEA10
Current schemes:
scheme_code |
Source | Notes |
|---|---|---|
SRS_INDIA |
icd-10-CODES_SRS_India.xlsx |
|
CMEA10 |
icd-10-CODES_CMEA10_mapped.xlsx |
|
WHO_2022_VA |
WHO_2022_VA_Bucket_Mapping_document_derived.xlsx plus WHO_2022_VA_Bucket_Mapping_admin_overrides.csv |
The CSV holds 34 ICD codes the WHO derivation missed, added through the admin bucket editor and frozen so a fresh install reaches the same mapping count as a long-lived one. An administrator's later repointing of one of those codes wins over the frozen value. |
WHO_2022_VA_2026 |
WHO_2022_VA_Bucket_Mapping_document_derived_2026_revision.xlsx |
The WHO 2026 annex ranges. Sixteen codes deliberately depart from the annex; those departures are documented and pinned by a file-based test. Migration c5f2a8d1e9b3. |
mapping_version moves when a scheme's mappings are re-imported, so a
consumer can tell that a scheme's content changed without diffing its rows.
icd11_method (crosswalk | native | NULL, CHECK-constrained; migration
62a637f5c38a) says how an ICD-11 code finds its bucket in the scheme
(policy: docs/policy/icd11-cod-bucket-schemes.md). NULL is an ICD-10-only
scheme, which every scheme was before. WHO_2022_VA_2026 is native:
migration 6c11b620f48f seeds its generated ICD-11 rows from
resource/who_2022_va_2026_icd11_native_mappings.csv (output of
flask cod-buckets generate-icd11 --scheme WHO_2022_VA_2026 --apply) and adds
its vas_11_01 "Fresh stillbirth" leaf beside vas_11_02. ICD-11 KD3B.1
maps there; ICD-10 P95 cannot separate fresh from macerated and stays with
vas_11_02. Re-importing or resetting a scheme from its workbook
rebuilds its nodes, which drops the ICD-11 rows with them (and
vas_11_01, which the source workbook does not have), so a native scheme
falls back to NULL until the generator runs again.
mas_cod_bucket_scheme_age_bands
Purpose:
- stores age-band metadata for a COD reporting scheme
Key fields:
scheme_idage_scopeage_labelmin_age_valuemin_age_unitmax_age_valuemax_age_unitlevel_countsort_order
Current behavior:
- lower bound is inclusive
- upper bound is exclusive
- all age bands persist explicit min/max bounds; no
NULLopen-ended ranges - built-in open-ended scopes currently use
120 yearsas the explicit upper cap - unit normalization for reporting uses:
days = 1months = 365 / 12years = 365
mas_cod_bucket_nodes
Purpose:
- stores hierarchy nodes inside a COD reporting scheme
Current behavior:
- supports variable depth by
scheme_id + age_scope - node types currently used are:
categorysubcategoryfield
map_icd_cod_buckets
Purpose:
- maps an ICD code to one reporting leaf within a scheme and age scope
Current behavior:
- one ICD code can map to only one leaf per
scheme_id + icd_classification + age_scope(unique constraint and theupper(icd_code)unique index) icd_classification(icd10|icd11, defaulticd10; migration62a637f5c38a) says which classificationicd_codebelongs to. Existing rows areicd10. Every ICD-10 consumer (reporting, the COD bucket panel's editor, search and unmapped grid, JSON import, the xlsx export, the ICD-10 browser's manual-override column) filters onicd10explicitly. The JSON export carries the field andicd11_method; a file without them imports as ICD-10 only.- ICD-11 rows are generated, not hand-edited:
match_typerangeorsplit,source_categorythe WHO VA cause code,mapping_notethe covering range. The panel shows them read-only behind an ICD-10 | ICD-11 toggle; the mapping PATCH/DELETE endpoints refuse them. va_submission_cod_snapshot_mvstill joinsWHO_2022_VArows without a classification filter: historical migrations rebuild that view through the same SQL builder, so it cannot reference a column that did not yet exist.WHO_2022_VAhas no ICD-11 rows, and an ICD-10 code can never equal an ICD-11 one (their second characters differ).
Organization Master Tables
Health-system projects describe their hierarchy with these per-project tables
(policy: docs/policy/organization-model.md; plan:
docs/planning/health-system-organization-model-plan.md). Projects without
rows here keep the Project > Site > Form model.
mas_org_level
- one row per level of a project's tree:
level_code,level_name,depth(1 = top),is_optional,is_active - unique on (
project_id,level_code) and (project_id,depth) - the ODK form field for a level is
org_<level_code>_code
mas_org_unit
- one row per unit:
unit_code(unique per project),unit_name,org_level_id,parent_org_unit_id,path(PostgreSQLltreeof unit codes, GiST-indexed),address,phone,latitude,longitude,google_maps_url,remarks,is_active - subtree queries use
path <@ :ancestor_path
mas_cadre
- per-project cadres:
cadre_code(unique per project),cadre_name,is_active
map_org_level_cadre
- which cadres exist at a level:
org_level_id,cadre_id,can_fill_va_form,can_code_va_form,can_supervise_interviews(migrationd7f3b1a9c5e2),can_report_deaths(migrationf2j6n9r4u1x7; default false, set for ANM and MPW atsubcentreand ASHA atvillagewhere those rows exist),default_roles(migrationg3k7o1s5w9a2, digitva-vjt;varchar[]not null, default empty, CHECK limits it to the unit-scope roles; the roles pre-ticked when a person of that cadre is granted at that level, validated on save against the row's flags withCADRE_FLAG_BY_ROLE; never a grant itself; a new grid takesDEFAULT_TYPICAL_ROLES, an existing row starts empty),is_active; unique on (level, cadre)
mas_org_unit_worker
- people attached to a unit:
worker_code(unique per project),worker_name,org_unit_id,cadre_id,phone, optionaluser_id(va_users),remarks,is_active - name and phone are personal data
Mentoring institutes
mas_mentor_institute: cross-project master (institute_codeunique,institute_name,is_active,created_at,created_by_user_id); not an org unitmap_mentor_institute_org_unit: institute to district-level unit, composite PK (institute_id,org_unit_id),is_active,created_at,created_by_user_id; cascades on institute or unit delete; index onorg_unit_idmap_mentor_institute_user: institute to staff user, composite PK (institute_id,user_id), same trailing columns plusis_admin(institute admin, default false; migrationd7e3a1c9b5f2); index onuser_id- many to many on both maps; they only guard grant writes
(
app/services/mentor_institute_service.py), they confer no access. Migrationc4a9e7d2b6f1. Policy:docs/policy/organization-model.md
Unit-scoped access grants
va_user_access_grantsacceptsscope_type = 'org_unit'withorg_unit_id(FKmas_org_unit) and an optional descriptivecadre_id(FKmas_cadre)- a unit grant leaves
project_idandproject_site_idempty; the grant's project is the unit's project, resolved in queries through the unit - the grant covers the unit's whole subtree (
path <@ grant unit path) - check constraints: the scope shape, the role/scope pairs (
collaborator,collaborator_pii,coder,coding_tester,reviewer,data_manager,interviewermay useorg_unit;interview_supervisoranddeath_reporter(migrationf2j6n9r4u1x7) may use onlyorg_unit;site_pimay useproject_siteororg_unit, the In-charge, since migratione2b7c4d9a1f3, which reversedc4a9e7d2b6f1's tightening; the unit rowsc4a9e7d2b6f1deactivated stay deactive), andcadre_idonly on unit grants - partial unique index
uq_va_user_access_grants_org_uniton (user_id,role,org_unit_id); lookup index on (org_unit_id,role,grant_status) - resolution lives in
app/services/org_grant_service.py; coding and reviewer enforcement still runs offva_forms, so a unit grant does not yet change what a coder may open
Submission routing to units
map_project_site_odk.org_unit_id(nullable, FKmas_org_unit): the fallback unit for submissions of that ODK form whose payload carries no usable unit codeva_submissions.org_unit_id(nullable, indexed, FKmas_org_unit): the unit a death is attributed tova_submissions.org_unit_resolution:form_field,mapping_fallbackormanual, constrained to those values and required wheneverorg_unit_idis set (and forbidden when it is not)va_submissions.org_unit_pinned_by/org_unit_pinned_at: who pinned the unit by hand and when; a check constraint allows them only alongsideorg_unit_resolution = 'manual'- index on (
org_unit_id,org_unit_resolution) for the unrouted queue - rules in
app/services/org_unit_routing_service.py, applied by ODK sync and by web intake
Several ODK forms per project-site
map_project_site_odkis unique on (project_id,site_id,odk_project_id,odk_form_id), not on (project_id,site_id): one DigitVA project accepts submissions from several ODK Central forms over one connection- each mapping materializes its own
va_formsrow; the two are matched on project, site, ODK project and ODK form.va_forms.odk_project_idis text while the mapping's is an integer, so joins compare them as text - the reverse rule still holds and is enforced in
app/services/odk_form_mapping_service.py: one ODK form belongs to one project-site per connection _next_form_idalready allocated sequential ids per project-site (PRJ01ST0101,…02), so several forms per pair need no identity change
Project coding scope
va_project_master.coding_scope_level_id(nullable, FKmas_org_level): the level within which a death may be coded; NULL means no unit-based scopeva_project_master.above_scope_coding_mode:code_anyorview_only(check-constrained), governing coders granted above that level- a project with a scope level must use
pick_and_choosecoding intake
Migrations: c8d2e4f6a1b3 (additive; enables the ltree extension),
d9e3f5a7b2c4 (additive; adds the org_unit scope value and the two grant
columns), c1d4e7f9a3b6 (additive; adds the routing columns),
e2a5c8b1d7f3 (widens the mapping uniqueness constraint),
f7b2d4e6a8c9 (additive; adds the project coding-scope columns).
Instrument Versions
mas_instrument_versions
Added 2026-10-05 (a8c4e2f6b9d1, beads digitva-xuf9 / digitva-6pwq).
- one row per composed form version the server has served; surrogate
id, unique(instrument_code, version) version:<WHO version>-<first 10 hex of SHA-256>of the composed all-extensions definitionactivated_at(timestamptz, defaultnow()): when this server first served itdefinition(JSONB): the complete composed definition with each conditional-extension item taggedextensions: [..], so an upload filled on that version can be re-checked against its rules (web_form_relevance_service,version=)- written by
served_form_service.record_served_version, once per process per version,ON CONFLICT DO NOTHING; never edited
Instrument Translation Tables
Added 2026-09-19 (WP6 of
docs/planning/web-capture-project-configuration-plan.md). A display language
is data, not part of the instrument bundle; the instrument's structure stays
pre-built and immutable.
mas_instrument_locales
- one row per display language of one standard instrument; PK
(instrument_code, locale_code) language_name,is_active(default false — set only by an explicitset_locale_activecall; coverage is reported but never gates it, decided 2026-09-19),source_document,source_sha256,imported_at,version,updated_atlifecycle_state(draftdefault /in_review/approved),approved_by_user_id(FKva_users, nullable),approved_at(nullable) — added 2026-09-20: a locale must beapprovedbefore it can beis_active, enforced both inset_locale_active/set_locale_lifecycle_stateand byck_mas_instrument_locales_active_requires_approved(is_active = false OR lifecycle_state = 'approved'). Enteringapprovedsetsapproved_by_user_id/approved_at; leaving it clears both toNULL. Migrationa3f7c1d9e6b4backfills every existing row toin_reviewand forcesis_activetofalse— a deliberate mass-deactivation, not a no-op (see the migration's docstring and "Instrument Translations Panel" indocs/current-state/admin-and-setup.md).a3f7c1d9e6b4and the model both passed the naming convention's already-prefixed name, which doubled the prefix and Postgres truncated the stored name tock_mas_instrument_locales_ck_mas_instrument_locales_act_5121(digitva-we0); migrationd2b5c7f9e4a3renames the live constraint to the name above, and the model now passes only the discriminator so a fresh database gets the same name directly. The same mistake was present in thirteen otherCheckConstraintdeclarations across the models (four truncated the same way); migrationfd232fab5987renames each live constraint and every model now passes only the discriminator (digitva-liu). When declaring aCheckConstraintname, pass only the discriminator (e.g.name="depth_positive"), never the already-prefixed form (e.g.name="ck_mas_org_level_depth_positive") — the naming convention inapp/__init__.py(ck_%(table_name)s_%(constraint_name)s) adds theck_<table>_prefix itself, and passing it again doubles it, truncating past 63 characters on longer names.versionis bumped by every import and every edit; clients cache a locale by it and revalidate againsttranslation_versionsin the form-options payload or the serving endpoint'sETagenis the base locale of every instrument: always served, always first, and has no row here- locale codes are the XLSForm
label::Name (code)codes and are a different axis frommas_languagescodes (khaversuskhasi); nothing maps between them
map_instrument_translations
- one translated string; PK is the five key columns
(instrument_code, locale_code, item_kind, item_key, field), which is also the uniqueness the importer upserts on item_kindisquestionorchoice;item_keyis the questionnameorlist_name+/+ the choicename;fieldislabel,hint,guidance_hintorconstraint_message(the last added 2026-10-06, digitva-8go.1;fieldis a plain string with no CHECK, so it needed no schema change -- migratione9h3k6p2s8v4only seeds 429 Hindimachinedrafts of constraint messages and guidance notes)sourceisimported,editedormachine(added 2026-09-20, digitva-4kj: an LLM-authored draft awaiting human review, e.g. migrationb6d2f4a9c1e7's 214 seeded rows, relabelled fromimportedby migrationc1a4b6e8d3f2). Precedence highest first:edited>imported>machine— a re-import or bulk XLIFF hand-back overwritesimportedandmachinerows alike and never aneditedone.export_translationsand coverage both exclude amachinerow; the panel's Accept action promotes it straight toedited.updated_byFKva_users,updated_at- FK
(instrument_code, locale_code)->mas_instrument_locales, ON DELETE CASCADE - roughly 2,800 rows per fully translated locale of
WHO_2022_VA - Seeded on a fresh install (added 2026-09-20, digitva-dms): migration
7134cb5dc7b6creates the twelve DigitVA-layer locale rows (draft, inactive) when absent and inserts the same 214 stringsb6d2f4a9c1e7seeds, assource='machine'from the start (b6d2f4a9c1e7only inserts where a locale row already exists, so a fresh database got none of them).
map_instrument_translation_suggestions
Migration g3k7n1s5v9y2 (digitva-5op). One district-staff suggestion for one
instrument translation string; accepting writes through update_string
(source edited), so this table is the audit of who suggested and who decided.
ididentity PK;instrument_code+locale_codeFK ->mas_instrument_locales(ON DELETE CASCADE);item_kind,item_key,field(the string, as inmap_instrument_translations)proposed_text;seen_text(the served translation the suggester saw, NULL if none: the staleness check on accept);reason(<= 1000)project_idFK ->va_project_master: the project it was suggested in, whose project PI may decidesuggested_by,suggested_at;status(pending|accepted|rejected, CHECK);decided_by,decided_at,decision_note(CHECK: a decided row has a decider and a time)- indexes
ix_mits_locale_status(instrument_code, locale_code, status, id),ix_mits_project_status(project_id, status, id), and the partial uniqueuq_mits_pending_per_user(suggested_by, instrument_code, locale_code, item_kind, item_key, field) WHERE status = 'pending' - policy:
docs/policy/va-form-project-configuration.md, "District review and suggestions"7134cb5dc7b6reads its rows from the checked-inresource/digitva_layer_translations_2026_09_20.csvrather than a third copy of the literal —b6d2f4a9c1e7andc1a4b6e8d3f2are applied and must not be edited, so they keep their own inlined literal copies;tests/migrations/test_seed_layer_translations_on_fresh_install.pychecks the CSV against both, byte-for-byte. Precedent for a checked-in data file read by a migration:resource/icd11_mms_2026_01_hierarchy.csv/mas_icd11_mms, below.
mas_ui_string_override
Migration m4v8r2t6q1w9 (digitva-fsjz). One administrator's correction to one
Expo UI string in one locale, served to every browser and phone without a new
build. The bundled JSON under mobile/digitva-collect/src/strings is untouched.
- PK (
locale_code,string_key): one correction per key and locale, so the pair is also the unique key.locale_codeString(16),string_keyString(255). text(Text, NOT NULL);updated_byFK ->va_users(NULL allowed);updated_at(timestamptz, NOT NULL, server defaultnow(); the service sets it in Python).source(String(16), NOT NULL, server defaultedited, CHECK inmachine/edited), migrationq7c2n5x9d4b8(digitva-cqw9).machinerows are Azure drafts, left out of_corrections, the version, the API and the export. The downgrade also refuses while machine rows exist.- No index or table of its own beyond the PK. A revert deletes the row.
The version of a locale is computed from
count(*)andmax(updated_at)over its rows, not stored. - Downgrade refuses while rows exist (corrections are data).
- Written by
app/services/ui_string_service.py; policydocs/policy/ui-strings.md.
The reference an item's key is checked against
instrument_translation_service.reference_items() is the WHO base workbook
plus the DigitVA layer entries from the committed
vendor/who-va-2022/src/generated/digitva-layers.reference.json artifact
(built by tooling/who-va-2022/build-layer-reference.mjs; Python only reads
it). reference_item_extensions() maps each reference key to the extension
name(s) it belongs to (a WHO base item maps to an empty set); base survey-label
coverage (reference_label_keys()) deliberately excludes layer labels, so it
keeps its original meaning, and per-extension coverage is reported alongside
it. A layer entry colliding with a WHO base key raises rather than one
silently shadowing the other — the two namespaces are disjoint today.
What a submission records
va_web_intake_drafts.meta carries {"locale", "translation_version"} from
the draft's start and every locale switch, and build_web_payload copies them
into the payload as intake_locale and intake_translation_version, so what
the respondent was shown stays reconstructible.
Web Intake Cases
Policy: docs/policy/web-intake.md, "Case worklist and interview states";
migrations c4e8a2f6b9d3 (digitva-vzk.4, worklist phases 2 and 3) and
e5b2c8d4a1f7 (digitva-vzk.9, phase 5: visits, contact attempts, phones,
address).
va_death_register (the case)
One row per death, for all three routes: source = 'register' (the
register form), 'direct' (a questionnaire started directly, which creates
its case at once), or 'odk' (an ODK-only interview, created by the sync in
a tree project; registered_by is the "ODK sync" service account, which cannot
sign in; a partial unique index uq_va_death_register_odk_va_sid on va_sid
where source = 'odk' allows one per submission). status is the case state, written only by
app/services/case_transition_service.py:
draft_identity, registered, scheduled, in_progress, paused,
not_reachable, refused, submitted, duplicate, cancelled.
deceased_name,deceased_sex,date_of_deathare nullable; CHECKck_va_death_register_identityrequireshas_deceased_name(see "Sealed personal data" below), the date and the sex unless the state isdraft_identityorcancelled. A direct start's draft saves fill them fromId10017/Id10018,Id10019andId10023(elseId10023_a/_b, else 1 July of theId10024year when the exact date is unknown); form edits on a registered case flow back the same way.date_of_birth_partial(String(7), nullable; migrationb7d4e9f2a6c1):YYYY-MMorYYYYwhen the day, or day and month, of birth is unknown. CHECKck_va_death_register_date_of_birth_partial_formatholds the format; CHECKck_va_death_register_date_of_birth_exact_or_partialforbids it besidedate_of_birth. Prefill mapping: docs/policy/web-intake.md.latitude,longitude,accuracy_m(Float) andcaptured_at(timestamptz, UTC), all nullable (migrationq7w2e5r8t1y4): the optional death-site GPS fix. CHECKsck_va_death_register_location_range,_location_all_or_none(a full fix, withcaptured_at, or none; readshas_location, so a sealed fix passes) and_location_accuracy(>= 0).captured_atis server time at save. Personal data with the address's audience; never logged. Policy: docs/policy/web-intake.md, "Death-site GPS location".started_by_user_id(FKva_users): who opened the first interview. The migration backfilled it from each case's earliest draft.pending_flag(duplicate|cancel) andduplicate_of_death_id(self FK): an interviewer's flag waiting for a supervisor.- The migration mapped the old statuses forward:
va_in_progress->in_progress,va_submitted->submitted;registeredandcancelledkept their names. Its downgrade maps back (scheduled->registered;paused,not_reachable,refused->va_in_progress;duplicate->cancelled) and refuses while any case has no identity. - Index
ix_va_death_register_updated (updated_at, death_id)serves the supervisor list's keyset paging. A draft save bumps the case'supdated_at(last activity). Indexix_va_death_register_registered_by (registered_by, updated_at, death_id)(migrationf2j6n9r4u1x7) serves a death reporter's own list ("registered by me, newest activity first", keyset-paged). - Index
ix_va_death_register_va_sid (va_sid)(migratione2b7d4a9c6f1) serves the area dashboard's death-to-interview medians, which join the register to submissions byva_sid. next_visit_at,last_contact_at(timestamptz, nullable): the appointment or follow-up date and the latest contact attempt. Indexix_va_death_register_next_visit (next_visit_at, updated_at, death_id)serves the worklist sort (next visit, undated last, then last activity) and its keyset paging (GET /api/v1/intake/cases). Starting an interview clearsnext_visit_at.informant_phone_2(String(32)) besideinformant_phone; both hold a validated Indian mobile as 10 digits for rows registered from phase 5 on (older rows may hold free text).address_house_street,address_village_ward,address_landmark(Text, 200 characters by the service) beside the free-textaddress. All PII; they stay on the case and are not copied into the submission payload. Plaintext today; encryption at rest of these and the payload PII fields is proposed in PII Encryption at Rest.father_name,mother_name(Text, nullable, 200 characters by the service; migrationc5e2b7a9d4f6,digitva-vzk.1): optional register-form fields, PII. They reach the payload only as the editable prefill of WHOId10061/Id10062(asked for children and neonates only).Sealed personal data (migration
e5a2c7b9d3f1, PII slice 2 T1; policy: PII Encryption at Rest, "Register design"; pii-slice-2.md). Nothing is sealed at upgrade.pii_sealed(JSONB, nullable;{field: "v1:aes256gcm:..."}),pii_dek_wrapped(Text),pii_key_id(String(16)),pii_blind_key_id(String(8)),pii_sealed_at(timestamptz). WithREGISTER_PII_SPLIT_ENABLEDon, a write sealsdeceased_name,father_name,mother_name,informant_name,informant_phone,informant_phone_2,abha_number,abha_address,address,address_house_street,address_village_ward,address_landmark,place_of_death,remarksand the GPS fix (latitude,longitude,accuracy_mas one fieldlocation) and NULLs those columns.informant_phone_bidx,informant_phone_2_bidx,abha_number_bidx,abha_address_bidx(String(128), nullable) are blind indexes with partial indexesix_va_death_register_project_<bidx> (project_id, <bidx>) WHERE <bidx> IS NOT NULL;ix_va_death_register_pii_key (pii_key_id) WHERE pii_sealed IS NOT NULLserves rotation. Presence flags (Boolean not null, default false):has_deceased_name,has_informant_phone,has_informant_phone_2,has_location; filled by the migration from the plaintext and kept by a mapper listener (non-null plaintext sets the flag; a NULL clears it only on an unsealed row) and by the seal writer. Filters andidentity_completeread the flags, never the sealed column. The downgrade refuses while any row is sealed. Readers (T2) go throughregister_pii_accessor.open_register_pii: the serialisers, prefill, the worklist and the shared case list open a page's rows once, only for fields the viewer may see; the case list's SQL selects the blob under the same visibilityCASEas the name, and the export (with_names=False) selects and opens nothing.
map_case_transitions
One audit row per case creation, state change or flag: death_id, action
(short code: created, interview_started, identity_captured,
submitted, draft_discarded, flag_duplicate, details_edited and
identity_from_interview (the changed field names in reason, never their
values; state unchanged), ...), from_state (NULL on
creation), to_state, reason (at most 200 characters, no personal data),
actor_user_id, authorizing_grant_id and authorizing_cadre_id (the
supervisor grant and its cadre a supervisor action relied on; NULL for team,
starter and registrant moves and for rows before migration a8d4f1c7e3b9;
foreign keys to va_user_access_grants and mas_cadre, not indexed),
changes (JSONB, nullable; migration d8q4e1h6n3v9, digitva-uq6v: on
details_edited and identity_from_interview rows, {field: {"old": ...,
"new": ...}} of each case detail changed, dates as ISO strings; personal
data, kept out of reason, never logged or returned by an API; NULL on
other rows and rows before the migration), changes_dek_wrapped (Text) and
changes_key_id (String(16)) (migration e5a2c7b9d3f1: with
REGISTER_PII_SPLIT_ENABLED on, the old/new values of the sealed register
fields inside changes are v1:aes256gcm: ciphertexts under this one wrapped
DEK, AAD = map_case_transitions, <field>.old|new, transition_id; other
fields stay plain; both NULL otherwise), created_at. Indexed on (death_id, created_at) and
(actor_user_id, death_id); the second serves the worklist's "mine" filter
(cases a user registered, started or acted on).
map_user_notifications
Migration h2n5q8t1v4w7, digitva-hdrv; policy docs/policy/app-notifications.md.
One polled nudge per user per event: id (BIGINT identity, the poll cursor),
user_id (FK va_users), kind (at most 32 characters: revision_requested,
other_draft_started, case_submitted_by_other, case_reopened,
interview_chosen),
project_id, death_id, draft_id and va_sid (all ids, nullable, no
foreign key so a purge or case change never waits on them), created_at
(default now()). No name, phone or answer. Named map_* because it maps a
user to an event. Indexed on (user_id, id) (the poll) and created_at (the
purge). Rows are inserted in the event's transaction by
notification_service.notify and deleted after 30 days by a daily beat task.
map_case_contact_attempts
One row per attempt to reach a case's family: attempt_id, death_id (FK
va_death_register), attempted_at, outcome (CHECK
ck_map_case_contact_attempts_outcome: reached, no_answer,
wrong_number, moved, refused), next_visit_at (nullable), by_user_id
(FK va_users), created_at. Outcome only, no notes. Indexed on
(death_id, attempted_at), (by_user_id, death_id) and (attempted_at)
(migration b8d2e5f1a7c3); the second serves the worklist's "mine" filter,
the third the area staff view's 30-day attempt counts. Written by web_intake_service.log_contact_attempt;
a state change the attempt causes is also audited in map_case_transitions.
Device uploads and device sessions
Migration d7a3c9e1f5b2 (digitva-kmk.1): va_web_intake_drafts.client_draft_id
(the Android app's draft UUID, unique where not null) and a draft status
superseded (a device upload, or a browser submit, for a closed case, kept,
never submitted; migration e6a2c9d4f1b7, digitva-xz83, adds the partial
unique index uq_va_web_intake_drafts_user_death_open on (death_id,
user_id) where status = 'draft': one open draft per interviewer per case)
and replaced (the losing version of a draft edited on the phone and in the
browser: history only, one history section, client_draft_id NULL; no
migration, status is free text; digitva-xz83 part B);
auth_device_enrolment_codes, auth_devices and auth_device_sessions
hold hashed enrolment codes, device secrets and tokens. See
Device Collection API.
Migration c7a3e9d1f5b8 (digitva-5bzf.4): auth_devices.client_device_uuid
(UUID, nullable, plain index ix_auth_devices_client_device_uuid): the Collect
Plus install UUID sent at enrolment; repeats across rows on re-enrolment, so
not unique.
Migration b9e4f2a7c1d3 (digitva-gb8w.5, docs/policy/offline-bundle-and-transfer.md
3.2): auth_devices gains signing_public_key (raw 32-byte Ed25519 public
key, bytea), signing_key_id (String(16), indexed; first 16 hex of the
key's SHA-256), signing_key_set_at and revoked_compromised (boolean, not
null, default false: a lost or stolen device whose bundles are all refused).
auth_device_signing_keys keeps every key a device has held (current one
with retired_at NULL), unique on (device_id, signing_key_id), so bundles
signed before a re-key still verify. Additive; downgrade drops all of it.
Migration f3a9c6d2e1b7 (digitva-gb8w.4, digitva-gb8w.8; contract
docs/policy/offline-api-contract.md 4.1; model app/models/auth_station.py):
va_users.user_number (BIGINT identity, unique, filled for every existing
user at upgrade) is the integer user id the offline station roster and bundle
actor_user_id carry, since user_id is a UUID. auth_station_custodians
(PK device_id FK auth_devices; name, designation code, phone
10 digits or null, email or null, CHECK one of them, onboarded_on,
verified_by FK va_users, updated_at): the station's custodian.
auth_station_roster (id, device_id, user_id FK va_users, unique
together; designation, roles JSONB [{role, project_id, site_id,
org_unit_id}], active, updated_by, updated_at). auth_station_configs
(PK config_id; device_id, issued_at, expires_at, issued_by,
signed_text the exact two-line file, text_sha256 its ETag; index
(device_id, issued_at)): every config issued, the newest in force.
Additive and empty at upgrade; the downgrade refuses while a config has been
issued.
Migration a7d3e9b2c4f6 (digitva-gb8w.2; bundle spec
docs/policy/offline-bundle-and-transfer.md 3.3, 5.6; model
app/models/va_bundle_ingest.py): va_bundle_ingests (PK bundle_id,
the replay guard; device_id FK auth_devices, project_id, bundle_seq,
manifest_sha256, station_config_id FK auth_station_configs,
kem_key_id, uploader_user_id FK va_users, channel browser or
device, created_at as claimed, received_at, status processing or
processed, counts, report JSONB, receipt_text; indexes
(device_id, received_at) for the per-station rate and (device_id,
bundle_seq) for missing_seq): one row per real ingest, never for a dry
run. map_bundle_records (PK client_id; bundle_id, record_type,
content_sha256 of the decrypted record, death_id, applied_at; index
(death_id, applied_at)): one row per applied record, for cross-bundle
idempotency (same hash duplicate, other conflict) and the correction
base_client_id stale check. Neither holds personal data. Additive; the
downgrade refuses while an ingest is recorded.
Migration c5e8a1f4b2d9 (digitva-gb8w.2; bundle spec 5.5; model
app/models/va_bundle_ingest.py): va_bundle_pending_coding (PK
client_id of the coding_result; bundle_ids uuid[] of every ingest whose
report names it pending, the first the original; project_id, va_sid FK
va_submissions, death_id, actor_user_id FK va_users,
content_sha256 of the decrypted record, result JSONB with the masked
coding fields only (client_draft_id, coded_answers_sha256, kind,
initial / final / not_codeable, coding_mode), status pending,
applied or rejected, code (the pending reason while pending:
not_ready, allocation_exists, doris_pending; the refusal once
rejected), created_at, resolved_at; indexes va_sid and
(status, created_at)): a coding result held until its case leaves
smartva_pending / attachment_sync_pending, or an ICD-11 DORIS final the
applier reprocesses (digitva-lmxr). No informant data, no
ciphertext. On applied a map_bundle_records row is added. Additive; the
downgrade refuses while a row is pending.
Migration f3a8c1d6e2b9 (digitva-l7c2, docs/policy/mobile-sign-in.md):
va_users.email is nullable (still unique); va_users.mobile_login
(String(10), unique) is the canonical sign-in number, kept in step with the
free-text phone by user_account_service.assign_phone and set only when
no other account holds the number (as mobile_login or as the canonical
form of its phone); va_users.mobile_verified_at records a mobile-only
account's first redeemed code; CHECK ck_va_users_email_or_mobile requires
one of email or mobile_login. auth_mobile_codes holds one-time sign-in
codes (HMAC hash, issuer, issued/expiry times, failed attempts, redeemed or
voided time). The migration backfilled mobile_login only for numbers held
by exactly one account (dev 2026-10-03: 103 of 107 valid numbers; two shared
numbers and four 9-digit numbers left for correction); its downgrade refuses
while any account has no email.
Migration c7p3d9k2m5t8 (digitva-04u4, digitva-ci8; policy
docs/policy/people-and-roles-page.md): two nullable columns on va_users.
job_title (String(120)) is a person's public post, written only through
user_account_service.clean_job_title, never redacted, never consulted for
access. last_signed_in_at (timezone-aware) is set by
VaUsers.mark_signed_in on a completed web sign-in and a device session
opening. The web_sign_in event in auth_security_events now also carries
the client IP in detail.ip (no schema change; stored only when it parses as an address; the daily
wipe_sign_in_ips_task removes it from events older than 210 days).
Death Registration Identifiers
Migration c7e3a9d1f5b2 (digitva-kzyp stage 1; policy
Death Registration Identifiers;
models in app/models/map_death_identifier.py). Three additive tables, no
change to va_death_register, no backfill; the downgrade drops them.
mas_death_identifier_type
Per-project types of official number (CRS, MCCD, certificate): identifier_type_id
(UUID PK), project_id (FK va_project_master, indexed), code
(String(32), unique with project_id), label, min_length (default 1),
max_length (default 64), char_type (nullable; digits, letters,
letters_digits, letters_digits_hyphen_slash), pattern (String(200),
nullable, safe subset and re2 compile checked on save), example (plaintext hint),
required_at_report, uniqueness_scope (none or project), sort_order,
is_active, timestamps. CHECK constraints on the scope, the length range
(1 <= min <= max <= 64) and char_type. Never deleted, only deactivated.
map_death_identifier
One encrypted value per type per death: death_identifier_id (PK), death_id
(FK va_death_register), identifier_type_id (FK), value_ciphertext
(v1:aes256gcm:...), dek_wrapped (v1:aes256gcm+mlkem768x25519:<key_id>:...),
value_blind_index (hex HMAC-SHA-512, String(128)), key_id, entered_by,
entered_at, updated_by, updated_at. unique(death_id, identifier_type_id);
non-unique ix_map_death_identifier_type_bidx (identifier_type_id,
value_blind_index) (a duplicate number is a warning); ix_map_death_identifier_key
(key_id) for the re-wrap job. No plaintext column exists. Written by death_identifier_service.set_identifiers (stage 2): the AAD is death_id.bytes || identifier_type_id.bytes, built by death_identifier_service.aad for both write and read; a cleared value deletes the row.
map_identifier_audit
Append-only configuration and export events: audit_id, project_id, event
(type_created, type_updated, export_with_identifiers), actor_user_id,
actor_role, identifier_type_id (nullable), details (JSONB: changed rule
fields {field: {old, new}}; never a value), created_at;
ix_map_identifier_audit_project_created (project_id, created_at). Stage 1
writes the two type events. Value writes use map_case_transitions
(action = identifiers_set, changes keyed identifier:<code> with blind-index
traces only; legacy rows may still hold a mask member), not this table.
Payload Version PII
map_payload_version_pii
Migration d4f1b8c2a6e9 (PII slice 1b part I; policy
Personal Data Encryption at Rest,
"Payload design"; model app/models/map_payload_version_pii.py; status
pii-slice-1b.md). Additive. Empty until the
release: the writers are wired (part II-A) but write it only with
PAYLOAD_PII_SPLIT_ENABLED on, which stays off until the release (the readers are
switched, part II-B); flask pii split-payloads --apply fills it for every existing
version in the release window, and --restore joins it back. While it is
empty payload_data holds every field. The downgrade drops it.
With the flag on, va_submission_payload_versions.payload_data holds no
field of the form type's sealed set (pii_field_registry.sealed_field_ids,
OD9), payload_fingerprint is payload_pii_split.version_fingerprint (the
stripped payload plus this row's pii_digest; flag off it stays
canonical_payload_fingerprint), and
va_submission_upstream_changes.previous_va_data / incoming_va_data are
stripped copies. The only writer of both together is
submission_payload_version_service.write_payload_version.
One row per payload version: payload_version_id (PK, FK
va_submission_payload_versions), va_sid (FK va_submissions, indexed),
sealed (JSONB {field_id: "v1:aes256gcm:..."}, each value the JSON text of
the answer, all under one data key per row; AAD map_payload_version_pii ||
0x00 || field_id || 0x00 || payload_version_id bytes), dek_wrapped,
key_id (ix_map_payload_version_pii_key, for the re-wrap job), pii_digest
(keyed digest the version fingerprint is built from, so comparisons never
decrypt), sealed_ids (JSONB sorted list: the sealed set the digest was
computed under; every comparison against the row splits both sides on it, so
an admin is_pii toggle never reads as a change before split-payloads
--apply reruns), abha_number_bidx and abha_address_bidx (String(128),
nullable, partial indexes on not null), blind_key_id (first 8 hex of
SHA-256 of the blind key), review_flags (JSONB list of field ids the
national-ID sweep flagged; ids only), created_at, updated_at. Read and
written only by app/services/payload_pii_accessor.py. Deleted with its
version by the synthetic demo reset (DELETE_TABLES).
Respondent Consent
Migration e8c4f2a7b913 (digitva-gb8w.3; contract
Offline API Contract section 5; model
app/models/va_consent.py; service app/services/consent_service.py).
Additive and empty at upgrade: a project without a settings row does not
require consent, so every existing project behaves as before. No table holds
informant personal data. The downgrade refuses while va_consent_records
has a row.
mas_project_consent_settings
One row per project (PK project_id, FK va_project_master):
consent_required (default false), contacts (JSONB, the 5.2 contacts
object: deletion, mentor_institution, central_team, dpo or null,
districts keyed by district org_unit_id), retention_period_days
(null: until the project ends), expiry_action (erase|lock),
refusal_behaviour (interview_without_informant_data), updated_by,
updated_at. Written only by consent_service.save_settings.
mas_consent_notice_versions
notice_version_id (PK), project_id, version (int >= 1), language,
text, text_sha256 (hex SHA-256 of the text's UTF-8 bytes as stored),
purposes (JSONB [{code, label, required}] by code), covered_fields
(JSONB), allowed_modes (JSONB subset of spoken, tap, signed),
witness_allowed, contacts_snapshot (the settings' contacts when created),
created_by, created_at, retired_at (null: current; no API sets it yet),
approved_at / approved_by (FK va_users; null: pending legal review; set
once by an admin, consent_service.approve_notice_version, migration
d4a7c9e2f5b1). Unique (project_id, version, language). The text,
purposes and fields are never updated: a change is a new version; only the
approval is set later.
va_consent_records and va_consent_record_purposes
Append-only (a trigger, va_consent_refuse_update, refuses UPDATE on both;
the app has no delete path, the synthetic demo reset deletes its own
project's rows through DELETE_TABLES). va_consent_records: consent_id
(PK), client_consent_id (the client's client_id, unique: the idempotency
key), project_id, death_id (FK the case), client_draft_id (the interview
attempt, null for a registration consent; the spec sketch's draft_id),
notice_version_id (FK), notice_language, notice_sha256, event
(granted|refused|withdrawn; withdrawal is digitva-athy), mode,
witness_present, supersedes_consent_id (FK self), recorded_by,
recorded_at (client clock), received_at, channel (web|device|
bundle), content_sha256 (fingerprint of the client object, case and
recorder, telling a resend from reuse of a client_id). Indexes
ix_va_consent_records_death (death_id, received_at),
ix_va_consent_records_client_draft (client_draft_id).
va_consent_record_purposes: PK (consent_id, purpose_code), required
(as the version said), granted.
map_consent_audit
Append-only configuration events (consent_settings_saved with
{field: {old, new}}, consent_notice_created with version, language,
text hash and purpose codes): audit_id, project_id, event,
actor_user_id, actor_role, notice_version_id (nullable), details,
created_at; ix_map_consent_audit_project_created.
Training Sessions
The Training & demo project (TRN01, docs/policy/training-demo-project.md)
keeps its trainings in three tables. They are additive (migration
e3b7d1a9c4f2); no existing table changed. A session's cases are ordinary
va_submissions rows under the TRN01 web form, so every case screen, query and
export treats them like any other case.
mas_training_sessions
One training. training_session_id (PK), project_id (FK
va_project_master), session_code (S001, S002, ... per project; unique
with project_id), name, created_at (timezone-aware), created_by (FK
va_users), org_unit_id (FK mas_org_unit, unique: the session root unit,
PHC level), status (active or closed) and coder_slots (1 to 100,
default 20; migration f6b2d8a4c1e7; the session has coder_slots x 10
cases). Index
(project_id, created_at).
map_training_cases
One row per case copy. va_sid (PK and FK va_submissions, trn-<code>-<n>),
training_session_id (FK), case_number (1 to 1000, unique per session),
coder_slot (1 to 100), source_va_sid (plain text: the source case belongs to
another project; not unique, since a source case may be copied into several
slots of one session) and source_final_cod (the source case's authoritative final
cause, the trainer's answer key; read only by admin-only code, never rendered
on a coder screen). Index (training_session_id, coder_slot).
map_training_participants
An existing user added to a session in one role (death_reporter,
interview_supervisor, interviewer, coder, reviewer_data_manager).
participant_id (PK), training_session_id and user_id (FKs), role,
coder_slot (1 to 100; set exactly for coders), grant_ids (uuid array: the
va_user_access_grants written for the role, two for
reviewer_data_manager), added_at, added_by, onboarded_at (null until
the user pressed Start on the welcome page) and status (active or
removed). Unique (training_session_id, user_id, role); unique
(training_session_id, coder_slot) where the slot is set; index
(training_session_id, role); a partial index on user_id where
status = 'active' AND onboarded_at IS NULL serves the sign-in onboarding
check.
Removing a participant or closing a session changes only the status of the
listed grants; nothing here is deleted. The tables are not in the DEMO01 reset
scope, but map_training_cases is named in its dependency list
(synthetic_demo_service.DELETE_TABLES) because it references
va_submissions.
ICD Reference Master Table
mas_icd10_2019_2
Purpose:
- stores the ICD-10 2019 hierarchy as denormalized master reference data for coding and future policy curation
Key fields:
codetitlenode_typesemantic_levelparent_codechapter_codeblock_codethree_character_codesort_orderis_coding_selectablesex_selectableage_group_selectableis_active
Current behavior:
- one row per ICD hierarchy node
- includes chapters, blocks, three-character categories, and detailed dotted codes
- includes modifier-derived detailed rows expanded from the WHO ClaML XML, such
as
V01.1,V10.4, andE10.0 - fresh migration creation seeds the table from the checked-in ICD hierarchy CSV
- coder ICD search now reads from this table for selectable three-character and detailed-code lookup
- coding-time age policy supports
all,neonate,infant,child, andadult; submission age is classified as<28 days,28-<365 days,365 days-<12 years, and>=12 years - preserves local policy flags in the same table
- import is idempotent:
- existing rows are updated by
code - newly introduced rows are inserted
- source-missing rows are marked inactive rather than deleted
- existing rows are updated by
mas_icd11_mms
Purpose:
- stores the WHO ICD-11 MMS linearization (a given
release, e.g.2026-01) as master reference data, mirroring themas_icd10_2019_2policy pattern. See docs/policy/icd11-reference-catalog.md.
Key fields:
id(surrogate uuid primary key)release,linearization_uri(unique together — the stable key per release)foundation_uri(nullable — residual categories have none)code(nullable — chapters and blocks have none),block_idtitle(WHO's-depth prefixes stripped)class_kind(chapter|block|category),depth_in_kind,chapter_nois_residual,is_leaf,primary_tabulation(informational only — WHO leaves its meaning undefined),coding_note,sort_orderparent_foundation_uri(from the export'sParentcolumn),parent_linearization_uri(resolved on import)is_coding_selectable,sex_selectable,age_group_selectable,policy_status,restriction_note(DigitVA-local policy fields, same semantics asmas_icd10_2019_2)is_active,source_version,source_path
Current behavior (phases 1-2 of docs/planning/icd11-coding-screen-integration-plan.md):
- one row per linearization entity in the frozen WHO Simple Tabulation export
(
docs/icd-causegrp-mappings/migration-artifacts/icd11-mms-2026-01-base-2026-09-16/) - fresh migration creation seeds the table from the checked-in generated CSV
(
resource/icd11_mms_2026_01_hierarchy.csv) - import (
app/services/icd11_mms_service.py::import_icd11_mms_from_export,flask icd11 import) is idempotent and streamed/batched, upserting on(release, linearization_uri); source-missing rows are marked inactive, never deleted; policy columns are preserved on rerun unlessapply_policy_columnsis set - admin browser and policy editor at
/admin/panels/icd11-browser(app/routes/admin_icd11.py); policy is curated there or throughflask icd11 policy-export/policy-import, which share one JSON format va_project_master.icd_classification(icd10|icd11|selectable, defaulticd10, CHECKck_va_project_master_icd_classification; migration33dea3ea3303) is the only source of a death's coding classification, for ODK and web-form submissions alike; resolved byapp/services/icd_coding_value.py::get_icd_classification_for_submission(submission ->va_forms.project_id-> project).selectablelets the coder pick ICD-10 or ICD-11 per death with a switch on the coding screen (va_form_partials/_icd_classification_switch.html). Saves go throughvalidate_coding_value_for_submission, which checks a fixed project's one catalogue, or in aselectableproject the catalogue the value's code shape names. ICD-11 coding search:GET /api/v1/icd11/coding-search/<va_sid>. Policy: docs/policy/va-form-project-configuration.md ("5. ICD classification")map_project_site_odk.icd_classificationis deprecated (kept for rollback; not read, written or shown). Migration33dea3ea3303moved its values up to the project: one value -> that value, both values ->selectable- the ICD-11 per-code allowability policy is signed off (2026-09-29) and
shipped by migration
a7c3e9f1b5d2: release2026-01rows still never reviewed take the frozen policy (resource/icd11_mms_2026_01_policy_signoff_2026_09_29.json) and are markedreviewed; seedocs/policy/who-2022-icd11-coding-allowability.md
mas_va_cause_definitions
Purpose:
- WHO VA cause codes, titles and definitions shown to coders and reviewers
(the "VA Definitions" modal and
/help/va-definitions), editable by admins. Policy: docs/policy/va-cause-definitions.md.
Key fields:
id(uuid primary key),va_code(unique cause code, e.g.VAs-01.01; WHO section headings andVAs-98are groups and are not stored)title,definition_html(sanitized rich text, allowlist inapp/utils/rich_text.py),is_active,sourcecreated_at,updated_at,updated_by(nullable FK tova_users,ON DELETE SET NULL; set only by an admin edit, and protects the row fromflask va-definitions importunless--force)
Current behavior:
- migration
fba41e2f1f9dcreates the table and seeds 63 rows fromresource/va_cause_definitions_who_2022.json;flask seed runloads the same file, skipping existing codes - read by
GET /api/v1/va-definitionsin one query, inva_codeorder;GET /api/v1/va-definitions/for-icdjoinsmap_icd_cod_buckets->mas_cod_bucket_nodes(vas_01_02->VAs-01.02) to find the definition for a selected ICD code; edited through/admin/api/va-definitions(app/routes/admin_va_definitions.py)
Submission Table
va_submissions
Purpose:
- stores imported ODK submissions after preprocessing
Key fields:
va_sidva_form_idva_submission_dateva_odk_updatedatva_data_collectorva_odk_reviewstateva_sync_issue_codeva_sync_issue_detailva_sync_issue_updated_atva_instance_nameva_uniqueid_realva_uniqueid_maskedva_consentva_narration_languageva_deceased_age(fromfinalAgeInYears; web and device payloads get it from the server, seedocs/policy/who-2022-age-derivation.md)va_deceased_age_normalized_daysva_deceased_age_normalized_yearsva_deceased_age_sourceva_deceased_genderva_datava_summaryva_catcountva_category_list
Current behavior:
va_form_idpoints tova_forms.form_idva_dataholds the processed ODK row as JSONB- additional structured and derived fields are extracted for workflow and UI use
- normalized age fields store sync-time WHO age precedence outputs for downstream analytics
va_odk_reviewstatemirrors ODK Central review state locally for dashboard visibilityva_sync_issue_*captures local sync-health markers such asmissing_in_odk- coder-ready counting paths are indexed for common filters:
va_form_idlower(va_narration_language)- join key
va_sid
Workflow Tables
va_allocations
Purpose:
- reserves one submission for one user for coding or review
Key fields:
va_sidva_allocated_tova_allocation_forva_allocation_statusva_allocation_training(boolean, NOT NULL, default false; true for an allocation inside the training project,DIGITVA_TRAINING_PROJECT_ID)- timestamps
Indexes: ix_va_allocations_to_for_createdat on (va_allocated_to,
va_allocation_for, va_allocation_createdat) serves "this user's most
recent coding or reviewing allocation", which picks the project a queue
opens on (digitva-umc4, migration j7n3q9w5y2e8). The partial unique
index uq_va_allocations_active_to_for on (va_allocated_to,
va_allocation_for, va_allocation_training) WHERE va_allocation_status =
'active' (created by a7c3e9f1d5b2, digitva-igq7; rebuilt over the side by
d6a1f3b8c2e4, digitva-rcwt, which also adds the column and marks existing
training-project rows) enforces at most one active coding and one active
reviewing allocation per user and side (training or real work); an allocator whose insert is refused
resumes the held allocation or answers allocation_exists.
va_submission_payload_versions
Purpose:
- holds every version of a submission's answers, one row per version, with
version_statusinactive,pending_upstream,supersededorrejectedand a unique-active index perva_sid
Current behavior:
payload_datais the answers as stored;va_submissions.active_payload_version_idnames the version in forcevalidation_err(JSONB,NOT NULL DEFAULT '[]') records the server's own disagreements with the client'svalid: truefor that version --{"question", "rule"}whereruleisrelevant,constraintorevaluation_error. Never enforced and never carries an answer value: it exists so the disagreement rate can be measured before anyone decides whether to start refusing submissions. Per-version deliberately, so a resubmission carries its own record rather than overwriting its predecessor's. Migrationf2031819b3aa; policy:docs/policy/va-web-form-options.md.
Interviewer revisions (migration f7b3d9e1a5c4, digitva-bhpl part A): nullable
revision_reason_code (String 32; a fixed code, never free text) and
answers_sha256 (String 64; SHA-256 of the exact answers text the client
sent) on the version a revision creates, and answers_sha256 on a web or
device submission's first version when the draft had one. Also the partial
index ix_va_web_intake_drafts_va_sid (va_sid where not null) on
va_web_intake_drafts. A submitted draft stores its complete raw answers in a
final section; a revision replaces it and keeps the previous answers as a
replaced draft row. See Device Collection API.
va_submission_workflow
Purpose:
- stores one canonical local workflow-state row per submission
Key fields:
va_sidworkflow_stateworkflow_reasonworkflow_updated_by_roleworkflow_updated_byworkflow_created_atworkflow_updated_at
Current behavior:
- this table is additive and coexists with legacy workflow tables
- current rows are backfilled from active legacy records
- current route integration updates the row on:
- coder allocation start
- initial COD submit
- final COD submit
- coder Not Codeable submit
- data-manager Not Codeable submit
- sync-created submissions
- stale coding allocation release
- coder dashboard availability and coder intake selection now read this table
- data-manager triage also writes directly to this table
- completion history and recode behavior still rely on legacy workflow tables in parallel, so this table is the canonical state store under migration, not yet the sole source of truth
- coder-ready workflows are additionally indexed on
(workflow_state, va_sid)to reduce join/filter cost in coder and dashboard APIs
va_icd_codes
Deprecated as of 2026-04-20.
Purpose:
- stores searchable ICD code catalog entries for coding forms
Key fields:
icd_codeicd_to_displaycategory
Current behavior:
- API search supports both code-prefix and display-text matching
- performance indexes include:
lower(icd_code)(prefix/code search)gin(lower(icd_to_display) gin_trgm_ops)for text matching
- primary ICD lookup behavior has moved to
mas_icd10_2019_2 - remaining
va_icd_codesusage is legacy compatibility and source-workbook support, not the primary runtime ICD catalog
mas_cod_bucket_schemes
Purpose:
- stores versioned reporting taxonomies for ICD bucket aggregation
Key fields:
scheme_codescheme_namemapping_versionsource_pathis_active
Current behavior:
- separate from submission COD data
- examples include
SRS IndiaandCMEA10
mas_cod_bucket_nodes
Purpose:
- stores the hierarchy nodes inside a reporting scheme
Key fields:
scheme_idage_scopenode_typeparent_node_idnode_codenode_label
Current behavior:
- supports variable hierarchy depth
- supports age-scoped trees, used by
SRS India - supports flat schemes, used by
CMEA10
map_icd_cod_buckets
Purpose:
- maps an ICD code and optional age scope to a reporting leaf node
Key fields:
scheme_idage_scopeicd_codenode_idsource_sheetsource_row_number
Current behavior:
- leaves coded submissions unchanged
- drives reporting-only COD bucket aggregation
icd_classification(icd10|icd11) is part of the unique key; see the COD Reporting Master Tables section above
va_initial_assessments
Purpose:
- first coding pass by coder
Key fields:
va_sidva_iniassess_byva_immediate_codva_antecedent_codva_other_conditionsicd11_provenance(nullable JSONB)doris_certificate,doris_result,codedit_result,cod_entry_mode_snapshot(nullable JSONB, migrationb8e2d4f6a1c3)va_iniassess_statusis_tester(Boolean, default false, migrationc4e8a1f7d2b3)
Current behavior:
a
coding_tester's Step 1 row is stampedis_testerat write time and deactivated when the tester finishes (digitva-ggc3)masked DORIS (masked ICD-11) Step 1 stores the verified certificate and DORIS/CoDEdit envelopes here;
va_immediate_codis the first condition on Part I line 1 andva_antecedent_codis the coder's confirmed underlying cause. The envelopes stay on this row; the final row links to it throughsource_initial_assessment_idva_reviewer_initial_assessmentshas the same four nullable JSONB columns; the masked DORIS reviewer's own Step 1 writes them the same way (its certificate starts as a copy of the coder's; the coder's row is not changed)
va_final_assessments
Purpose:
- final coding outcome by coder
Key fields:
va_sidpayload_version_idva_finassess_bysource_initial_assessment_idva_conclusive_codicd11_provenance(nullable JSONB)va_finassess_remarkva_finassess_statusdemo_expires_atis_tester(Boolean, default false, migrationc4e8a1f7d2b3)
Current behavior:
a
coding_tester's final COD on a real submission is written deactive withis_testertrue (digitva-ggc3): it never becomes the authority, never supersedes a coder's active final, and every count that reads active rows leaves it out. Rows from beforec4e8a1f7d2b3default to false and are not backfilled (grants keep no history, so old tester rows cannot be told apart)this table still stores the underlying coder final-COD records
- multiple historical rows may now exist for the same submission across recode episodes
- coder final-COD rows are now stamped with the submission's current
active_payload_version_id - coder final-COD rows now store explicit linkage to the exact Step 1 draft
used during final submit via
source_initial_assessment_id -> va_initial_assessments.va_iniassess_id - the active row alone is no longer the sole authority signal during the workflow migration
- normal coding leaves
demo_expires_atnull - demo coding stamps a temporary expiry timestamp
- admin-started demo sessions on ordinary projects use a 6-hour expiry
demo_training_enabledprojects usedemo_retention_minutes, default 10 minutes
va_reviewer_final_assessments
Purpose:
- reviewer-owned final coding outcome after reviewer secondary coding
Key fields:
va_sidpayload_version_idva_rfinassess_byva_conclusive_codicd11_provenance(nullable JSONB)supersedes_coder_final_assessment_idva_rfinassess_status
Current behavior:
- reviewer final-COD rows are now stamped with the submission's current
active_payload_version_id - reviewer final-COD history is stored separately from coder final-COD history
- active reviewer final-COD rows may supersede coder final-COD rows in authority resolution
All four coder/reviewer initial/final assessment tables have nullable
icd11_provenance JSONB. New ICD-11 selections store the full code expression,
canonical MMS title, selected display text, pinned release, and server-verified
WHO linearization and codeinfo URIs; foundation_uri is nullable. ICD-10 and
historical assessment rows remain NULL. The original COD text fields remain
the display values and are not rewritten by migration d9e0f1a2b3c4.
va_smartva_runs
Purpose:
- stores one durable SmartVA execution attempt per submission payload version
Key fields:
va_smartva_run_idva_sidpayload_version_idtrigger_sourceva_smartva_outcomeva_smartva_failure_stageva_smartva_failure_detailrun_metadata- run timestamps
Current behavior:
- one row is written per SmartVA attempt
- both successful and failed attempts are preserved
- each run is linked to the submission payload lineage through
payload_version_id
va_smartva_run_outputs
Purpose:
- stores emitted SmartVA output rows for a SmartVA run
Key fields:
va_smartva_run_output_idva_smartva_run_idoutput_kindoutput_source_nameoutput_sidoutput_resultforoutput_payload
Current behavior:
- stores the emitted SmartVA likelihood/output row as JSONB
- preserves per-run emitted output separately from the active projection row
- currently stores likelihood rows only
va_smartva_form_runs
Purpose:
- stores one SmartVA execution batch per form run
Key fields:
form_run_idform_idproject_idtrigger_sourcepending_sid_countoutcomedisk_pathrun_started_atrun_completed_atarchive_state(local|archived|failed|absent, NOT NULL, defaultlocal, indexed)archive_key_prefixarchived_atarchive_error_codearchive_file_countarchive_bytes
Current behavior:
- stores the on-disk workspace path for raw SmartVA debug artifacts, relative to
APP_SMARTVA_RUNS - groups multiple
va_smartva_runscreated during the same form execution - the archive columns record where that workspace lives now: with
ATTACHMENT_STORE=s3it is copied to the DigitVA bucket undersmartva_runs/{project_id}/{form_id}/{form_run_id}/, verified, and then removed from the VM — at which pointdisk_pathbecomes NULL andarchive_key_prefixholds the store-relative prefix archive_error_codeis a short category only (upload_failed,verify_mismatch,store_unavailable,walk_failed,local_delete_failed) — never a path, a key or a submission identifier- migration
d3b8f1e40a72; policy baseline SmartVA Generation Policy
Analytics Materialized View
va_submission_analytics_mv
Purpose:
- provides one-row-per-submission analytics data for trends, demographics, workflow, SmartVA, and human coding
Key fields:
- project/site/form identifiers
- submission day/week/month buckets
- workflow and sync states
- normalized age fields
- analytics age band
- human initial COD and authoritative final COD
- parsed human ICD prefixes
- SmartVA causes and ICD codes
cod_pending_upstream_review(boolean) —truewhenworkflow_state = 'finalized_upstream_changed'; allows downstream reports to distinguish cases with an unresolved upstream change from cleanly-coded cases
KPI policy (Option C):
coded_submissionsKPI includesfinalized_upstream_changedcases alongsidecoder_finalized,reviewer_eligible, andreviewer_finalized- the dashboard label for these cases is "Data Changed" (not "Revoked")
cod_pending_upstream_reviewis the flag that lets consumers distinguish them
Current behavior:
- populated by a PostgreSQL materialized view (rebuilt by migration
b1c2d3e4f5a6) - refreshed hourly by Celery Beat
- intended for reporting and chart APIs, not operational workflow writes
va_submission_upstream_changes
Purpose:
- durable record of each protected upstream payload change event
- captures a snapshot of what was authoritative at the time of the change so the accept/reject decision has full context
Key fields:
va_sid— FK tova_submissionsprevious_payload_version_id— FK tova_submission_payload_versions(was active before the change)pending_payload_version_id— FK tova_submission_payload_versions(new/incoming payload)previous_workflow_state_before— the submission state at the time of the changeprevious_final_assessment_id— FK tova_final_assessments(coder COD snapshot)previous_reviewer_final_assessment_id— nullable FK tova_reviewer_final_assessments(reviewer COD snapshot, if the submission wasreviewer_finalizedat the time); added by migrationaacf89977029change_status—pending/accepted/kept_current_icd/rejectedresolved_by— user id of the data manager or admin who resolved itresolved_at— when the resolution occurred
Current behavior:
- created by
record_protected_upstream_change()inapp/services/workflow/upstream_changes.pywhenever a protected submission receives a changed ODK payload - accepted by
dm_accept_upstream_change()— promotes pending payload to active, clears reviewer authority, routes tosmartva_pending - resolved by
dm_reject_upstream_change()— promotes pending payload to active, preserves finalized ICD/COD artifacts, restoresworkflow_state_before, and records thekept_current_icdresolution outcome
va_final_cod_authority
Purpose:
- stores the single authoritative final-COD pointer for each submission
Key fields:
va_sidauthoritative_final_assessment_idauthoritative_reviewer_final_assessment_idauthority_source_roleauthority_reasoneffective_atupdated_by
Current behavior:
- this table is additive and backfilled from the most recent active
va_final_assessmentsrow during migration - runtime COD panel rendering now prefers this table when deciding which final COD to show as current
- when a recode replacement final COD is submitted, the authority row is moved to the replacement final assessment
- when reviewer final COD exists, the authority row may instead point to
authoritative_reviewer_final_assessment_id - when sync invalidates a submission's finalized COD, the authority row is
cleared instead of relying only on
va_finassess_status - service-level authority resolution now rejects stale coder/reviewer final-COD
rows whose
payload_version_iddoes not match the submission's currentactive_payload_version_id
va_coding_episodes
Purpose:
- tracks additive coding episodes such as recode attempts without destroying the currently authoritative coder outcome
Key fields:
episode_idva_sidepisode_typeepisode_statusstarted_bybase_final_assessment_idreplacement_final_assessment_idstarted_atcompleted_atabandoned_at
Current behavior:
- currently used for non-destructive
recodehandling - only one active recode episode is allowed per submission
- starting recode creates an episode but does not deactivate the current authoritative final COD
- successful replacement final COD completes the episode and links the replacement final assessment
- stale allocation timeout or sync invalidation abandons the active recode episode without deleting historical COD rows
va_coder_review
Purpose:
- records coder decision that a submission is not codeable or has an issue
Key fields:
va_sidva_creview_byva_creview_reasonva_creview_otherva_creview_statusis_tester(Boolean, default false, migrationc4e8a1f7d2b3): acoding_tester's not-codeable report is written deactive with it set; the case is not excluded and ODK is not flagged (digitva-ggc3)
va_data_manager_review
Purpose:
- records data-manager decision that a submission should be kept out of coder allocation
Key fields:
va_sidva_dmreview_byva_dmreview_reasonva_dmreview_otherva_dmreview_status
Current behavior:
- one active row is allowed per submission
- this table is distinct from coder-owned Not Codeable records
- an active row drives canonical workflow state
not_codeable_by_data_manager
va_reviewer_review
Purpose:
- legacy reviewer quality/review outcome over a submission
Key fields:
va_sidva_rreview_by- review flags and remarks
va_rreviewva_rreview_status
Current behavior:
- this table stores the current legacy reviewer QA/quality-review form
- it does not store reviewer final COD
- it should not be treated as the target reviewer secondary-coding artifact
va_reviewer_final_assessments
Purpose:
- stores the additive reviewer-owned final COD artifact for the new reviewer secondary-coding model
Key fields:
va_sidva_rfinassess_byva_conclusive_codva_rfinassess_remarksupersedes_coder_final_assessment_idva_rfinassess_status
Current behavior:
- this table now exists in runtime schema
- reviewer final COD rows may coexist with coder final COD rows
- service-level authoritative-final-COD resolution can now prefer reviewer final COD from this table
- not all downstream readers have been cut over yet
va_narrative_assessments
Purpose:
- stores coder Narrative Quality Assessment answers for a submission
Key fields:
va_sidpayload_version_idva_nqa_by- six scored question fields
va_nqa_scoreva_nqa_statusdemo_expires_at
Current behavior:
- the table is now payload-version aware
- the current NQA for a coder is the active row whose
payload_version_idmatches the submission'sactive_payload_version_id - payload changes create a new current row on next save unless a protected keep-current-ICD decision rebinds the preserved row to the promoted payload
- normal coding leaves
demo_expires_atnull - demo coding (
vademo_start_coding) stamps a temporary expiry timestamp - admin-started demo sessions on ordinary projects use a 6-hour expiry
demo_training_enabledprojects usedemo_retention_minutes, default 10 minutes
va_social_autopsy_analyses
Purpose:
- stores coder Social Autopsy analysis for a submission as an app-owned workflow artifact
Key fields:
va_sidpayload_version_idva_saa_byva_saa_remarkva_saa_statusdemo_expires_at
Current behavior:
- the table is now payload-version aware
- the current Social Autopsy row for a coder is the active row whose
payload_version_idmatches the submission'sactive_payload_version_id - normal coding leaves
demo_expires_atnull - demo coding (
vademo_start_coding) stamps a temporary expiry timestamp - admin-started demo sessions on ordinary projects use a 6-hour expiry
demo_training_enabledprojects usedemo_retention_minutes, default 10 minutes- one active analysis row is stored per
(va_sid, coder)at a time; older payload versions are kept as inactive history - selected delay options are normalized into child rows, not flattened into the submission JSON
- demo coding stamps a 6-hour expiry timestamp on the parent row so completed demo artifacts can be cleaned up automatically
va_social_autopsy_analysis_options
Purpose:
- stores selected Social Autopsy delay-factor options under a parent analysis row
Key fields:
va_saa_iddelay_leveloption_code
Supporting Tables
Other important tables:
va_users—year_of_birth(Integer, nullable, 1900 to this year) andsex(String(16), nullable,female|male|undetermined, checked byVaUsers.set_interviewer_profile, no CHECK) from migrationc5e2b7a9d4f6(digitva-vzk.3): optional interviewer details, PII, set in Profile or by an admin. Web intake prefills and locksId10010bfromsex, and prefills (unlocked)Id10010afromyear_of_birthwhen the resulting age is 18-89 (owner decision 2026-10-20;docs/policy/web-intake.md).va_project_master— also carriesproject_target_completion_date(DATE, nullable), the admin-set target the DM burndown KPI projects against (app/routes/api/dm_kpi/dm_kpi_burndown.py), and the four web intake form options served byGET /api/v1/organization/<project_id>/form-options(migrationf2a9c4d7e1b3):web_intake_default_locale(String(16), NOT NULL, default'en'),web_intake_available_locales(JSONB, nullable — NULL means every active language),web_intake_narration_languages(JSONB, nullable — NULL means none offered) andweb_intake_show_guidance(Boolean, NOT NULL, default false). Migrationc3e8b5a1f4d2adds the web questionnaire and its two extension settings:web_intake_form_type_id(UUID, nullable, FKfk_va_project_master_web_intake_form_type->mas_form_types.form_type_id— NULL meansWHO_2022_VA),web_intake_intake_note(Text, nullable — NULL means the system default welcome note,''means no welcome screen) andweb_intake_death_summary_enabled(Boolean, NOT NULL, default true). Migratione1b6c9a3d7f4addsweb_intake_medical_records_enabled(Boolean, NOT NULL, default true), mirroringweb_intake_death_summary_enabled. Migrationb4f8d2e6a1c3addsweb_intake_equity_enabled(Boolean, NOT NULL, server default false):equityjoinsenabled_extensionsonly when it is true (docs/policy/digitva-who-form-overrides.md, "Equity extension"). Migrationb4k8m2r6w9x3addsself_coding_enabled(Boolean, NOT NULL, server default false): in a self-coding project a coder also interviews and may code their own case (ProjectSettings.self_codinginapp/services/authz/grants.py; it is on only whileweb_intake_modeis notoff, which the admin API enforces both ways). Policy:docs/policy/web-intake.md, "Self-coding projects". Policy:docs/policy/va-web-form-options.md,docs/policy/va-form-project-configuration.mdva_site_masterva_project_sitesva_user_access_grants— see Unit-scoped access grants for theorg_unitscope columns. Also carriescreated_by_user_id(UUID, nullable, FKfk_va_user_access_grants_created_by->va_users.user_id): the granting user, written on insert by the admin and data-manager grant routes and the project user import. A reactivated grant keeps its original value (insert-only); null means not recorded (CLI, seed, older grants). Read by the People & roles page as "granted by".va_usernotesva_smartva_form_runsva_smartva_runsva_smartva_run_outputsva_smartva_resultsva_submissions_auditlogva_icd_codesva_formsva_sitesva_research_projects
va_submission_attachments
Purpose:
- one row per (
va_sid,filename) ODK submission attachment - ETag cache for conditional GET, plus the local blob's opaque
storage_name - readiness state for the Central-backed attachment work
Key fields:
va_sid,filename— composite primary keylocal_path— actual path on disk; differs fromfilenamefor.amr, which is stored as its.mp3derivativemime_type— copied from upstream, not authoritative (may be the derivative's type for AMR rows)etag,last_downloaded_at,exists_on_odkstorage_name— opaque serving token (unique where not null)
Source/derivative state (added by migration b7e4c2a91d38, Phase 2 of the
Central attachment plan). Vocabularies are
constants in app/services/attachment_service.py; all timestamps are timezone-aware:
| Column | Type | Meaning |
|---|---|---|
source_state |
VARCHAR(16) NOT NULL, default unknown |
unknown, listed, available, missing, retired, error |
source_verified_at |
TIMESTAMPTZ | last observed Central content response |
source_error_code |
VARCHAR(32) | not_found, auth, throttled, transient, invalid_redirect, unknown — category only, never free text |
source_mime_type |
VARCHAR(64) | validated MIME of the original; mime_type is not repurposed |
derivative_state |
VARCHAR(16) | audio only: pending, ready, stale, error; NULL for non-audio rows |
derivative_mime_type |
VARCHAR(64) | e.g. audio/mpeg |
derivative_source_validator |
VARCHAR(128) | opaque source ETag the current MP3 was built from |
derivative_verified_at |
TIMESTAMPTZ | last derivative verification |
derivative_error_code |
VARCHAR(32) | e.g. conversion_failed |
local_fallback_state |
VARCHAR(16) NOT NULL, default present |
present, retained (archival copy of a retired submission — never deleted), quarantined, absent |
Store location (added by migration a4f1c07b62d9):
| Column | Type | Meaning |
|---|---|---|
store_state |
VARCHAR(16) NOT NULL, default local |
which store holds the blob: local (a file under APP_DATA/<form_id>/media/), s3 (an object in the DigitVA bucket; local_path is NULL for these rows), absent (not stored anywhere yet) |
Indexes:
ix_va_submission_attachments_sid_odk— partial,exists_on_odk IS TRUEix_va_submission_attachments_storage_name— unique, partial,storage_name IS NOT NULLix_va_submission_attachments_source_state— request-path readiness readsix_va_submission_attachments_derivative_state— partial,derivative_state IS NOT NULL, for repair candidate selection over the audio rows onlyix_va_submission_attachments_store_state— cutover tool and integrity check select rows by which store holds their object
Current behavior:
- sync writes
source_state='available'with the observed time and the original's validated MIME on a successful download,'missing'when ODK no longer lists the file, and the AMR derivative columns (including the ETag it was built from) when an MP3 is produced; a failed conversion recordsderivative_state='error',derivative_error_code='conversion_failed'and leaves the previous blob alone - with
ATTACHMENT_STORE=s3, sync uploads the blob and writesstore_state='s3',local_path=NULL,local_fallback_state='absent'; with the local store it keeps writingstore_state='local'and the file path exactly as before - bulk presence (
present_attachment_files_by_submission()) resolves ans3row fromstore_statealone and alocalrow from disk; it never probes the bucket per row. Object-level truth comes fromscripts/check_attachment_integrity.py --store s3
va_web_intake_attachments
Migration g4k8p2t6x1b5 (digitva-ej1). A file a web interviewer uploaded while the draft was still open; submit copies it into va_submission_attachments over the same stored object (Web Intake Policy, "Attachments"; Attachment Storage and Delivery Policy, "Web intake uploads").
- primary key (
draft_id,client_attachment_id):draft_id->va_web_intake_drafts.draft_id;client_attachment_idis the UUID the page made for the file, so a retried upload finds its row and stores nothing filename—<client_attachment_id><ext>of the detected type; the value the submission payload carries for the answer and theva_submission_attachments.filenameit becomes. The uploader's own filename is never keptstorage_name— opaque store token (unique),local_path(local store only),store_state(localors3)mime_type— validated type of the uploaded original, decided from its leading bytes (an.amris stored as an MP3 derivative)size_bytes,sha256of the bytes received,created_at(timezone-aware)
Indexes: the primary key; uq_va_web_intake_attachments_storage_name (unique); ix_va_web_intake_attachments_created_at (migration h5m2r8v4z1d9, the daily purge's age scan). Discarding a draft deletes its rows and their stored objects; rows of submitted drafts stay (their objects are held by va_submission_attachments). The daily purge (digitva-i9lb) deletes the rows of an open draft untouched for 30 days and rows no answer references after 30 days, never one a submission holds, and audits each draft as draft_attachments_purged in map_case_transitions.
map_project_site_odk
Purpose:
- stores the source-of-truth mapping from a project-site pair to an ODK project and form
- optionally links that mapping to a configured form type
Key fields:
project_idsite_idodk_project_idodk_form_idform_type_id
Current behavior:
- admin project form mapping writes here
- ODK sync now enumerates this table, not
va_forms, to decide what to sync - sync then materializes compatibility
va_formsrows so the legacy workflow stack continues to function
ODK Connection Tables
mas_odk_connections
Purpose:
- ODK Central connection master; one row per ODK server
Key fields:
connection_id— UUID primary keyconnection_name— unique human-readable name for the connectionbase_url— base URL of the ODK Central serverusername_enc— Fernet-encrypted ciphertext of the usernameusername_salt— per-row salt used when deriving the encryption key for the usernamepassword_enc— Fernet-encrypted ciphertext of the passwordpassword_salt— per-row salt used when deriving the encryption key for the passwordstatus— active/inactive status (VaStatuses)notes— optional free-text notescreated_at,updated_at— timestamps
Form-Type Mapping Tables
The field-mapping system is scoped by form type under the mas_* table family.
These tables are now the source of truth for structural display configuration.
mas_form_types
Purpose:
- registers supported form types such as
WHO_2022_VAandWHO_2022_VA_SOCIAL
Key fields:
form_type_idform_type_codeform_type_namebase_instrument_code— String(32), nullable (migrationc3e8b5a1f4d2, backfilledWHO_2022_VAfor everyWHO_2022_VA%code): the standard instrument this form type layers on. Read byinstrument_code_for()to tell the web form which bundled questionnaire to render; NULL means none is bundled. Policy:docs/policy/new-form-type-onboarding.mdbase_template_pathmapping_versionis_active
mas_category_order
Legacy compatibility table retained during the category-config cutover. Runtime
navigation and category admin now use mas_category_display_config as the
authoritative category table.
Purpose:
- stores category membership and base display order per form type
Key fields:
form_type_idcategory_codecategory_namedisplay_orderis_active
mas_category_display_config
Purpose:
- stores category-level display metadata per form type
Key fields:
form_type_idcategory_codedisplay_labelnav_labelicon_namedisplay_orderrender_modeshow_to_codershow_to_reviewershow_to_site_pialways_includeis_default_startis_active
Current seeded behavior:
- the schema is added by Alembic revision
c7f1d2e3a4b5 - the migration seeds deterministic rows for
WHO_2022_VAandWHO_2022_VA_SOCIAL - the current seed count is 14 rows for
WHO_2022_VAand 15 rows forWHO_2022_VA_SOCIAL
mas_subcategory_order
Purpose:
- stores subcategory order within a category for a form type
Key fields:
form_type_idcategory_codesubcategory_codesubcategory_namedisplay_orderis_active
mas_field_display_config
Purpose:
- stores field placement, labels, flags, and order per form type
Key fields:
form_type_idfield_idcategory_codesubcategory_codeshort_labelfull_labelsummary_labelflip_coloris_infosummary_includeis_piidisplay_orderis_active
mas_languages
Purpose:
- canonical language list for the application
- used by coder profile language selection, submission filtering, and sync normalization
Key fields:
language_code— primary key (e.g.bangla,english,hindi)language_name— display name (e.g.Bangla,English,Hindi)is_active— boolean; inactive languages hidden from profile selection
map_language_aliases
Purpose:
- maps raw ODK language values to canonical
mas_languages.language_code - lookup table used by
_normalize_language()during sync
Key fields:
alias— primary key (e.g.bn,bengali,Bengali)language_code— FK tomas_languages
Behavior:
- multiple aliases can map to one canonical code
- alias conflicts across languages are prevented (unique PK)
- unknown ODK values pass through unchanged and appear in admin unmapped alert
mas_choice_mappings
Purpose:
- stores per-form-type choice-value to label translations
Key fields:
form_type_idfield_idchoice_valuechoice_labeldisplay_orderis_active
Credential storage:
- credentials are encrypted using Fernet AES-128
- each credential field has its own per-row salt stored alongside it
- a shared pepper is read from the environment at runtime; it is not stored in the database
- multiple projects may share one connection
map_project_odk
Purpose:
- maps one app project to one ODK connection
Key fields:
id— UUID primary keyproject_id— String(6) foreign key to the project master, unique constraint enforcedconnection_id— UUID foreign key tomas_odk_connections
Behavior:
- unique on
project_id— one project has at most one ODK connection - a project without a row here falls back to the legacy
odk_config.tomlconnection during sync
map_project_site_odk
Purpose:
- maps a project-site pair to a specific ODK Central project ID, form ID, and VA form type
Key fields:
id— UUID primary keyproject_id— String(6) foreign keysite_id— String(4) foreign keyodk_project_id— Integer; the numeric project ID on the ODK Central serverodk_form_id— Text; the xmlFormId of the form on ODK Centralform_type_id— UUID nullable foreign key tomas_form_types; identifies which VA form type (e.g.WHO_2022_VA,WHO_2022_VA_SOCIAL) this site uses for field display and renderingcreated_at,updated_at— timestamps
Behavior:
- unique on
(project_id, site_id)— one ODK form per project-site combination - the ODK connection used for this mapping is derived via
map_project_odkand is not stored here directly form_type_idis optional but strongly recommended; if absent, rendering falls back to the hardcoded defaultWHO_2022_VA
va_sync_runs
Purpose:
- records every ODK sync run — start time, outcome, and submission-level metrics
Key fields:
sync_run_id— UUID primary keytriggered_by—"scheduled"or"manual"triggered_user_id— nullable FK tova_users.user_id(set for manual runs)started_at— indexed timestamp when the run beganfinished_at— null while the run is in progressstatus—"running"/"success"/"error"records_added,records_updated— submission counts from the completed runerror_message— first 2000 chars of the exception on failure
Current behavior:
- written by the
run_odk_syncCelery task inapp/tasks/sync_tasks.py - a
"running"row is committed before sync begins so the admin dashboard can display live status - stale
"running"rows older than 2 hours are marked"error"on worker restart
va_db_backups
Purpose:
- records every database dump — where it went, how big it was, and its sha256
Key fields:
backup_id— UUID primary keystarted_at— timestamptz, indexedDESC(ix_va_db_backups_started_at)completed_at— null while the dump is in progresstriggered_by—"scheduled"/"manual"/"cli"triggered_user_id— nullable FK tova_users.user_id(set for admin-triggered runs)status—"running"/"success"/"failed"/"pruned", indexed (ix_va_db_backups_status)store—"local"or"s3"object_key— the bucket key (or the file name on the local store); the only identifier a dump has outside the storesize_bytes,sha256— recorded on success; the sha256 is what a restore verifies againsterror_code— short failure category (pg_dump_failed,upload_failed,verify_mismatch,dump_too_large, …), never a message frompg_dumpor the transportpruned_at— set when retention removed the object
Current behavior:
- written by
app/services/db_backup_service.py, driven by therun_db_backupCelery task, theflask backupsCLI, and the admin Database backups panel - a
"running"row is committed beforepg_dumpstarts, so an interrupted run leaves evidence rather than nothing DB_BACKUP_KEEP_DAILYretention flips a"success"row to"pruned"when its object is deleted; the row is kept as history- this table is the only dump history on the VM — see backup.md
va_cod_bucket_scheme_snapshots
Purpose:
- a whole-scheme JSON snapshot taken automatically before every COD bucket
scheme "reset from source", so admin edits, ICD-11 rows and manual
overrides survive a rebuild that would otherwise silently drop them
(policy:
docs/policy/icd11-cod-bucket-schemes.md)
Key fields:
snapshot_id— UUID primary keyscheme_id— nullable FK tomas_cod_bucket_schemes.scheme_id,ondelete="SET NULL"(a deleted scheme's snapshots stay findable)scheme_code— duplicates the FK target's code so a snapshot survives scheme deletion; this is what routes filter and index byreason—"reset_scheme"/"reset_age_band"/"cli_import", CHECK-constrainedage_scope— nullable; what triggered the snapshot, not what it covers (the payload is always the whole scheme, since restore is whole-scheme)payload— JSONB, theexport_cod_bucket_scheme_json()outputcreated_by_user_id— nullable FK tova_users.user_idcreated_at— timestamptz; indexed withscheme_code,created_at DESC(ix_va_cod_bucket_scheme_snapshots_scheme_code_created_at)
Current behavior:
- written inside the same transaction as the reset it precedes
(
snapshot_cod_bucket_scheme()flushes, does not commit); if the snapshot fails, the reset is refused flask cod-buckets import-*also snapshots (reasoncli_import) before replacing an already-existing scheme; a fresh install skips it- restore is the existing JSON import (
import_cod_bucket_scheme_json); there is no dedicated restore endpoint, only list and download - no retention/prune job yet — add one if this table grows large enough to matter
Schema Drift Guard
The models must describe the live schema exactly — indexes, constraint names, foreign-key
names and ondelete included — so that autogenerate has nothing to say.
- Naming convention.
dbis constructed with aMetaData(naming_convention=...)inapp/__init__.py, so constraints the models leave unnamed get deterministic names. Theuqkey is%(table_name)s_%(column_0_name)s_key, PostgreSQL's own default, rather than the Alembic-standarduq_...: Alembic applies the convention to tables built byop.create_tabletoo, so the standard form would retroactively rename constraints that existing migrations already created under the PostgreSQL name. - Externally-owned tables.
app/schema_filters.include_objecthides thecelery_*scheduler tables and the Flask-Sessionva_sessionstable from the comparison.migrations/env.pyinstalls it for both the offline and online contexts. Check before committing a model change.
docker compose exec -T minerva_app_service uv run flask db checkIt is read-only and must print
No new upgrade operations detected.- Automated guard.
tests/migrations/test_schema_drift.pycreates a throwaway database, builds it withflask db upgradealone (nevercreate_all()), and asserts thatcompare_metadataagainstdb.metadatareturns nothing. The rest of the suite builds its schema withcreate_all()and so cannot see this class of drift.
Key Current-State Observations
- sites are modeled as project-owned
- forms are overloaded with multiple responsibilities
- submissions are keyed to synthetic app form identity
- legacy runtime permissions still center on
va_users.permission - explicit auth foundation tables now exist additively in
va_project_master,va_site_master,va_project_sites, andva_user_access_grants, but runtime authorization has not cut over yet - ODK identifiers are stored per app form
- ODK connection credentials are now stored encrypted in
mas_odk_connectionsrather than in a flat TOML file - per-site ODK project, form, and VA form type mapping is now managed via
map_project_site_odk - the current schema is suitable for one-project-first operation, not generalized multi-project reuse