Workflow & Permissions
Workflow And Permissions
Summary
After sync, the application runs a role-based workflow over va_submissions.
Primary roles:
- coder
- data manager
- reviewer
- site PI
- admin
Current admin scope rule:
adminis global-only in the implemented grant model- project- or site-scoped admin grants are not supported
- admin-only workflow actions such as override-to-recode therefore rely on global admin membership, not submission scope checks
Current demo/training access rule:
- a project may be marked as a training pool using
va_project_master.demo_training_enabled - active forms in those projects are treated as coder-accessible without any project-specific coder grant
- in practice, any active authenticated user can enter the coder flow for those demo/training project forms
- non-demo projects still require ordinary coder grants
- the coder dashboard now exposes a dedicated
DEMO-CODINGshortcut when at least one demo/training project is available to the current user - that shortcut preselects the first demo/training project on the page and
shows an inline warning that:
- completed demo/training codes persist for 10 minutes by default
- incomplete demo/training allocations are revoked after 15 minutes
Current coding tester gate rule:
coding_testeris a grant role that can enter non-demo coding while bypassing project-site gates:coding_enabledcoding_start_datecoding_end_datedaily_coder_limit
- coding gate date checks use UTC date boundaries
Current coding tester output rule (digitva-ggc3, security review 10 #12):
- a save is tester output when it is not a demo save (no
demo_expires_at) andauthz.codes_as_tester(user, va_sid)holds: a realcoding_testergrant reaches the submission and neither a realcodergrant nor a demo-training virtual grant does. A user holding both codes as a coder; an admin with no grant is never a tester - the tester's final COD or not-codeable report is stored deactive with
is_tester; their Step 1, NQA and social autopsy rows are deactivated, the allocation is released and the case movescoding_in_progress/coder_step1_saved -> ready_for_codingthrough thetester_coding_returnedtransition (coder actor). No coder final is superseded; the final-COD authority and any open recode episode stay; no ODK review state is written - because the row is deactive and the transition is not
coder_finalized/recode_finalized, tester output is absent from the coder dashboard KPIs, DM coder output, burndown, daily KPI aggregates, analytics MVs, exports and the 24-hour reviewer-eligibility sweep; DM coder utilization counts only allocations held by coder-grant holders; the 24-hour recode-limit counts ignoreis_testerrows - the tester's own coder history still lists their test codings, badged "Test · not counted"
The current workflow is built around form-based permissions and per-submission allocation.
Current payload-lineage rule for coder-owned supporting artifacts:
- coder NQA and Social Autopsy are now payload-version aware
- the current artifact for a coder is the active row whose
payload_version_idmatchesva_submissions.active_payload_version_id Accept And Recodedeactivates those current artifacts because coding will restart against new dataKeep Current ICD Decisionpreserves them by rebinding them to the promoted payload
Current authority-chain rule for finalized artifacts:
- reviewer-owned final COD is treated as downstream of coder-owned final COD
- if
Accept And Recodeis chosen for a protected upstream change, both coder and reviewer final COD artifacts are deactivated as current authoritative results - if
Keep Current ICD Decisionis chosen, both coder and reviewer final COD artifacts are preserved as current authoritative results, if reviewer artifacts exist for that SID
Current payload-lineage rule for reviewer supporting artifacts:
- reviewer review/NQA rows are now payload-version aware
- the current reviewer review is the active row whose
payload_version_idmatchesva_submissions.active_payload_version_id Accept And Recodedeactivates reviewer review rows because the reviewer conclusion chain is discarded with the coder chainKeep Current ICD Decisionpreserves reviewer review rows by rebinding them to the promoted payload
An additive canonical workflow-state table now exists:
va_submission_workflowva_submission_workflow_events
This table stores one local business-state row per submission, but the application is still in migration. Legacy workflow tables still drive completion history, recode behavior, and some reporting paths.
Current cutover status:
- a coder or reviewer holds one active allocation per kind and side (training
project or real work,
va_allocations.va_allocation_training); the Expo API allocates the training project only with?training=1, never otherwise (api-v1.md, "Training scope") - coder random allocation reads
va_submission_workflow - admin demo allocation reads
va_submission_workflow - coder dashboard available-form count reads
va_submission_workflow - coder dashboard authored-output KPIs now read the coder's active authored final assessments and active authored Not Codeable reviews, excluding demo-training projects
- coder dashboard "Cumulative Forms Coded" includes coder-authored active
final assessments even after workflow advances from
coder_finalizedtoreviewer_eligible - coder dashboard shows a separate authored
not_codeable_by_codercount - coder dashboard authored-output stats/history are cached and busted on coder final submission and coder Not Codeable submission
- project-level
coding_intake_modeis now stored onva_project_masterwithrandom_form_allocationas the default - coder dashboard now splits eligible coding intake by project mode:
random_form_allocationprojects use the existing start buttonpick_and_chooseprojects expose a browse-and-start list
- data-manager dashboard now exists as a scope-based browse/view workflow
- data-manager Not Codeable writes canonical workflow state
not_codeable_by_data_manager - final COD display now prefers explicit authority resolution through
va_final_cod_authority - recode now starts a separate non-destructive episode in
va_coding_episodesinstead of immediately discarding the current finalized coder outcome - workflow state ownership is being consolidated under
app/services/workflow/ - runtime state transitions now flow through
app/services/workflow/transitions.pyusing explicit actor types:vasystemvaadminvacoderdata_manager
- canonical state persistence is handled in
app/services/workflow/state_store.py - canonical workflow events are now written to
va_submission_workflow_events - transition execution now takes a row lock on the submission workflow record before validating and writing state, reducing concurrent transition races
Current State vs Desired State
Current implemented workflow states written by the runtime are a subset of the full policy target.
Implemented in current runtime:
consent_refusedscreening_pendingattachment_sync_pendingsmartva_pendingready_for_codingcoding_in_progresspartial_coding_saved(legacy compatibility only; current runtime no longer creates new rows in this state)coder_step1_savedcoder_finalizedreviewer_eligiblereviewer_coding_in_progressreviewer_finalizedfinalized_upstream_changednot_codeable_by_codernot_codeable_by_data_manager
Desired target states are documented in Coding Workflow State Machine Policy. The planned gap-closure sequence is documented in Plan: Finalized Upstream Change Gap Closure.
Current rename note:
- runtime/data now use
finalized_upstream_changed - legacy migrated key:
revoked_va_data_changed - UI target label remains
Finalized - ODK Data Changed
Send-back And Reopen Of Web Interviews
A finalised web or device interview can be opened for its interviewer's
revision (docs/policy/interview-revisions.md, rules 3 and 4). Both moves go
to finalized_upstream_changed through the upstream_change_detected
transition with a distinct transition reason, and leave the COD active until
the revision arrives (app/services/interview_send_back_service.py).
| Route | Who | Reason | From |
|---|---|---|---|
POST /api/v1/coding/submissions/<va_sid>/send-back |
the coder who authored the final COD; a reviewer in scope on a reviewer_eligible submission, holding the session (reviewer_coding_in_progress, released first) or having finalised it (reviewer_finalized) |
sent_back_for_revision |
coder_finalized, reviewer_eligible, reviewer_coding_in_progress, reviewer_finalized |
POST /api/v1/intake/supervision/submissions/<va_sid>/reopen-for-revision |
admin; interview_supervisor or data_manager (or In-charge, project PI) whose supervision reach covers the interview's case; for an interview with no case, a data manager of its scope |
reopened_for_revision |
coder_finalized, reviewer_eligible, reviewer_finalized |
A third move opens a finalised case for a different reason: a supervisor
choosing another interviewer's complete interview
(POST /api/v1/intake/supervision/cases/<death_id>/choose-interview,
choose_interview in app/services/web_intake_service.py;
Web Intake Policy, "Supervisors"). Who: admin;
interview_supervisor or data_manager (or In-charge, project PI) whose
supervision reach covers the case. From any state but
reviewer_coding_in_progress (409). A protected state moves to
finalized_upstream_changed through mark_upstream_change_detected with the
transition reason interview_chosen (the supervisor, data manager and admin
actors are admitted for this reason only; it is not a revision request, so
revision_unlocked stays false), and at once reopen_coding_after_revision
(source supervisor_choice, the same system accept, reason interview_chosen)
returns it to smartva_pending, the earlier COD kept as inactive history; an
unprotected state runs release_coding_for_changed_payload (source
supervisor_choice). The submission is then re-routed. The new payload version
carries the reason supervisor_choice.
Neither exists for ODK submissions (409 not_web_submission). The coder,
reviewer and interview-supervisor workflow actors are admitted to
mark_upstream_change_detected for these two reasons only. The interviewer's
changed revision (revise_submission) then runs the data manager's accept
block under a system actor (reopen_coding_after_revision) and the case
returns to smartva_pending; a data manager's reject cancels the open request.
Confirmed Duplicate Web Cases
A web case confirmed as a duplicate (va_death_register.status = duplicate,
va_sid set) drops its submission out of every coding reader through one
predicate in app/services/duplicate_exclusion.py; the submission's workflow
state is not changed. Confirmation (case_transition_service.transition)
revokes any active coding or reviewing allocation through
coding_allocation_service.revoke_active_allocations, which releases it like
a timeout. A supervisor reopen restores the case status and with it every
reader. Exclusion over the analytics materialized views is applied at query
time in submission_analytics_mv._mv_scope_filter; raw-SQL KPI queries pair
_NOT_DUPLICATE_SQL with every _IN_ODK_SQL. Policy and the reader list:
docs/policy/coding-workflow-state-machine.md, "Confirmed Duplicate Cases";
guard: tests/test_duplicate_exclusion_coverage.py.
Main Workflow Sequence
- ODK sync writes or updates
va_submissions. - The workflow layer routes new or payload-changed submissions to:
consent_refused, orattachment_sync_pendingA web intake submission enters the same way; only one whoseinterview_outcomeiscompletedgoes toattachment_sync_pending. A refused, partially completed or respondent-unavailable web submission is routed toconsent_refused(no SmartVA, no allocation) and its case waits inrefused,pausedornot_reachable(web_intake_service.submit_draft,OUTCOME_CASE_STATES;docs/policy/web-intake.md).
- Attachment completion for the current payload moves the submission to
smartva_pending. - SmartVA completion for the current payload moves the submission to
ready_for_coding. - Eligible submissions become visible in coding dashboards only after they are
ready_for_coding. - A user starts coding or review, which creates an allocation.
- The user works through the category UI and submits outcomes.
- The allocation is released when work is completed or a terminal workflow decision is recorded.
Coder Workflow
Project-level intake setting:
va_project_master.coding_intake_mode- supported values:
random_form_allocationpick_and_choose
Project-level screening note:
screening_pendingis an optional project-configured gate- screening-enabled projects may route submissions through
screening_pending -> smartva_pendingorscreening_pending -> not_codeable_by_data_manager - projects without screening bypass that state and route directly to
smartva_pending
Current implementation status:
- the setting is now editable in the admin Projects panel
random_form_allocationprojects still use the existing start-coding flowpick_and_chooseprojects now render an eligible-submission browse table on the coder dashboard- pick-and-choose start uses the dedicated
vapickcodingaction and still creates a normal coding allocation pluscoding_in_progressworkflow state
Coder dashboard behavior:
- the coder sees submissions only if:
- the submission's
va_form_idis in the coder's permitted forms - the form's
(project_id, site_id)pair is currently active inva_project_sites - the submission language is in the coder's allowed languages
- the submission's canonical workflow state is eligible for coding
- the submission's
- completed-history rows and the cumulative coded count now use canonical
workflow states:
coder_finalizednot_codeable_by_coder
- the dashboard still reads underlying final-assessment / coder-review rows for display timestamps and actor attribution during the migration period
Starting coding:
- the app creates a
va_allocationsrow for the chosen submission - the app also records
coding_in_progressinva_submission_workflow - stale coding allocations older than one hour are released automatically
- the release path deactivates only the stale coding allocation
- a coder may release their own allocation (
POST /api/v1/coding/allocation/release, "Release" on the coding dashboard): same effect, audited asva_allocation_released_by_coderunder the coder; the release transitions (incomplete_first_pass_reset,incomplete_recode_reset) accept the coder actor besides system and admin - in a self-coding project the submitter may take their own
ready_for_codingcase withPOST /api/v1/coding/submissions/<va_sid>/code-now(allocate_own_case; seeapi-v1.md) - any saved
va_initial_assessmentsrow is preserved so the coder can resume final COD later - admin demo coding also creates a normal coding allocation, but demo-created
NQA, Social Autopsy, and final COD artifacts now carry a
demo_expires_attimestamp - for ordinary projects started through admin demo mode, the expiry window is 6 hours
- for
demo_training_enabledprojects, the expiry window comes fromva_project_master.demo_retention_minutesand defaults to 10 minutes
Entry variants:
vastartcodingpicks from only the coder-accessible projects configured forrandom_form_allocationvapickcodingis available only for ready submissions in coder-accessible projects configured forpick_and_choose- a submission in a
demo_training_enabledproject automatically usesvademo_start_codingeven when entered from the normal coder start/pick flow, so demo retention and cleanup apply without a separate admin-only demo launch
Coding steps:
- initial assessment creates a
va_initial_assessmentsrow - initial assessment also records
coder_step1_savedinva_submission_workflow - final coding creates a
va_final_assessmentsrow - final coding stamps that row with the submission's current
active_payload_version_id - final coding also records
coder_finalizedinva_submission_workflow - not-codeable path creates a
va_coder_reviewrow - coder Not Codeable also records
not_codeable_by_coderinva_submission_workflow - when a coder marks a case Not Codeable, DigitVA saves the local outcome first
and then separately attempts to push
hasIssuesreview state to ODK Central
ICD coding allowability:
- ICD coding search filters selectable ICD10-2019-2 rows by the submission's classified age group and sex
- initial, final, and reviewer final COD save paths enforce the same ICD10-2019-2 coding policy server-side before creating new COD rows
- policy-disallowed posted values are rejected even if they came from stale UI options or manually edited requests
Completion behavior:
- final coding or not-codeable submission deactivates the active coding allocation
- demo final coding now keeps the saved NQA, Social Autopsy, and final COD rows active immediately after submission so they remain visible in the dashboard during the demo-retention window
- completed demo Step 1 COD is temporary; when its matching demo final COD expires, cleanup deactivates the same coder's active Step 1 row
- reviewer final coding creates a
va_reviewer_final_assessmentsrow stamped with the submission's currentactive_payload_version_id - final COD authority resolution now ignores stale coder/reviewer final rows from older payload versions
Timeout cleanup:
- the app still performs a stale-allocation release check when a coder starts normal coding
- a Celery beat task also runs every hour to release stale coding allocations
- normal coding allocations expire after 1 hour
- demo/training coding allocations expire after 15 minutes
- an interviewer's revision of a submitted interview releases the allocation
with audit action
interviewer_revision(ODK sync:va_allocation_released_during_datasync), through the samerelease_coding_for_changed_payload(docs/policy/interview-revisions.md) - timeout release writes a
va_submissions_auditlogrow withva_allocation_released_due_to_timeout - timeout release now reverts unfinished Step 1 COD drafts by deactivating the
timed-out coder's active
va_initial_assessmentsrow - first-pass timeout reversion also deactivates the timed-out coder's NQA and
Social Autopsy analysis rows so the submission returns to
ready_for_coding - recode timeout reversion preserves the authoritative final COD plus recode NQA
and Social Autopsy analysis rows, abandons the active recode episode, and
returns canonical workflow state to
coder_finalized - recode start/finalization now require an active recode episode as a workflow precondition, not just route/service branching
- the same hourly maintenance task now also deactivates expired demo-created
NQA, Social Autopsy, and final COD rows whose
demo_expires_attimestamp is older than the current time - demo/training project saved artifacts use the project retention window and default to 10 minutes
- that hourly maintenance path now moves
coder_finalized -> reviewer_eligibleonce the authoritative final COD is older than the 24-hour recode window and there is no active recode episode - when demo-retention cleanup deactivates an authoritative demo final COD, it
also clears or repoints
va_final_cod_authorityand restores canonical workflow state based on the remaining active records - demo-retention cleanup deactivates the expired demo coder's active
va_initial_assessmentsrow before restoring workflow state, so completed demo/training submissions can return toready_for_coding - demo-retention cleanup no longer emits a demo reset for submissions that still have an active coding allocation; in that case it prunes the expired demo artifacts but keeps the live coding or recode session state intact
Canonical state values currently written in the runtime path include:
consent_refusedready_for_codingcoding_in_progresspartial_coding_savedcoder_step1_savedcoder_finalizedreviewer_eligiblefinalized_upstream_changednot_codeable_by_codernot_codeable_by_data_manager
The closed state still exists as a defined legacy compatibility constant for
historical rows and protection logic, but current runtime does not write it in
normal case handling. Current runtime treats reviewer_eligible as the
post-24-hour resting state for coder-finalized submissions.
attachment_sync_pending is now written for newly synced and payload-changed
submissions while attachment batching finishes for the current payload.
smartva_pending is now written only after attachment syncing completes for
the current payload and before SmartVA completes.
Current remaining reader/reporting gap:
- analytics MV and Site PI reporting now honor reviewer authority
- some older coder-participation detail slices still read coder-owned tables directly because they are measuring coder activity, not authoritative final COD
Retired submissions and coding pools
A submission whose va_sync_issue_code is missing_in_odk is retired from ODK
(see ODK Retired Submissions Policy).
Every pool that can create a new allocation applies the shared predicate
submission_is_in_odk()
(app/services/odk_retirement_service.py),
never a locally re-derived comparison:
- coder random pool, pick-and-choose list, and the ready counts behind
/api/v1/coding/stats,/api/v1/coding/availableand the/coding/dashboard (_available_submission_filters()inapp/services/coder_workflow_service.py) - the demo/training pool (
start_demo_allocation()) - the recode offer list (
get_coder_recodeable_sids()) - the reviewer dashboard pool, which still shows a retired submission whose review is already done or still in session
Entry points refuse a direct URL with HTTP 409 and the policy message
(RETIRED_MESSAGE): POST /coding/pick/<sid>, the vapickcoding and
vastartreviewing validators, allocate_pick_form(),
start_recode_allocation() and start_reviewer_coding().
Existing allocations are unaffected: a submission retired mid-session keeps its
allocation until the normal timeout, so varesumecoding and
varesumereviewing still work, and completed coding is untouched.
Data Manager Workflow
Scope model:
- data-manager access is granted at:
projectproject_siteorg_unit(organizational projects): the unit and its whole subtree, on every data-management surface (see "Current effective model" below)
Current runtime behavior:
- a data manager sees all submissions in granted scope, regardless of coder allocation
- data-manager view uses the same category rendering shell in read-only mode
- category visibility currently follows the site-PI visibility configuration
- the left navigation adds a synthetic final panel:
vadmtriage
- the dashboard now exposes:
- ODK review state mirrored from ODK Central
- local sync issue status
- scoped form sync
- scoped single-submission refresh
Data-manager triage:
- the
Data Triagepanel can mark a submission Not Codeable only while the canonical workflow state is:screening_pendingsmartva_pendingready_for_codingnot_codeable_by_data_manager
- a successful triage write creates or updates
va_data_manager_review - the canonical workflow state is updated to
not_codeable_by_data_manager - coder availability excludes those submissions automatically because coder pool
selection now requires
ready_for_coding - the POST path now also enforces an explicit
current_user.is_data_manager()check before it records the transition
Data-manager sync controls:
- a data manager can trigger a force-resync for any form in granted scope
- a data manager can trigger a single-submission refresh for any submission in granted scope
- the single-submission refresh updates local submission data, attachments, and SmartVA result for that submission
Audit trail:
- canonical workflow state changes are recorded in
va_submission_workflow_events va_submissions_auditlogis now the non-workflow operational audit trail- current milestone examples include:
form allocated to codersocial autopsy analysis saved/updatednarrative quality assessment saved/updatedinitial cod submittedfinal cod submitted
error reported by coderodk review state set to hasIssuesodk review state update failed
Recode:
- only coder-finalized submissions are currently eligible for recode
- recode is now additive:
- starting recode creates or reuses an active
va_coding_episodesrow - the current authoritative final COD remains in force during the recode window
- successful replacement final COD supersedes the prior authoritative final COD and completes the recode episode
- starting recode creates or reuses an active
- the recode start window is currently twenty-four hours from the authoritative final COD timestamp
- stale allocation timeout abandons the active recode episode without deleting the previously authoritative final COD
- sync updates that invalidate an existing finalized COD also abandon any active recode episode and clear final-COD authority for that submission
Final COD authority:
- the coding UI now resolves "current final COD" through
va_final_cod_authorityfirst - fallback to the newest active
va_final_assessmentsrow still exists for backward compatibility during migration - a replacement final COD submission now:
- deactivates the superseded active final-assessment rows
- writes audit entries for supersession
- updates
va_final_cod_authority - completes the active recode episode if one exists
Coding Screen Left Navigation
The coder/reviewer left navigation is now built dynamically from:
- form-type category config
- live submission data visibility
- role-aware category rendering rules
The stored va_submissions.va_category_list remains a legacy derived field, but
it no longer controls the visible category flow in coding.
Reviewer Workflow
Reviewer dashboard behavior:
- reviewer visibility is filtered by permitted forms and allowed narration languages
Demo reviewing (demo_training_enabled projects):
- a demo coder final COD moves the case
coder_finalized -> reviewer_eligibleat once (demo_reviewer_eligible_immediately); admin-started demo sessions on ordinary projects keep the 24-hour recode window - demo project forms join every active user's reviewer scope, as they join coder scope, so any user may review demo cases; non-demo forms stay grant-only
- when the coder's demo final COD expires, demo-retention cleanup also
deactivates the case's reviewer final/initial COD, reviewer review, NQA,
Social Autopsy and reviewing allocation (audited), clears the reviewer
authority pointer, and
reset_demo_state(now allowed from the three reviewer states) returns the case toready_for_coding
Starting review:
- the app creates a reviewing allocation (
VaAllocation.reviewing) - state transitions to
reviewer_coding_in_progress
Reviewer NQA (supporting artifact, optional):
- the reviewer may submit a
va_reviewer_review(NQA) record during their session — this is a partial save only - NQA save does NOT release the reviewing allocation and does NOT advance workflow state; it is a supporting artifact equivalent to Social Autopsy Analysis for coders
Reviewer final COD (terminal action):
- the reviewer submits a final COD via
submit_reviewer_final_cod()(app/services/reviewer_coding_service.py) - this releases the reviewing allocation and transitions to
reviewer_finalized va_final_cod_authorityis updated to point to the reviewer's final assessment
Reviewer session timeout:
- stale
reviewer_coding_in_progressallocations are released byrelease_stale_reviewer_allocations()on a 1-hour schedule (and opportunistically atstart_reviewer_coding()entry) - on timeout: all intermediate artifacts (
va_reviewer_review,va_narrative_assessments,va_social_autopsy_analysesfor that user) are deactivated; state reverts toreviewer_eligible - policy:
docs/policy/coding-allocation-timeouts.md
Current reviewer model note:
- current runtime now marks post-24-hour coder-finalized submissions as
reviewer_eligible - runtime now also supports reviewer secondary-coding workflow states:
reviewer_coding_in_progressreviewer_finalized
- reviewer JSON API exists for reviewer allocation and reviewer final-COD
submission (
app/routes/api/reviewing.py) - the older
va_reviewer_reviewNQA flow is a supporting artifact; it does not control workflow state or allocation lifecycle - reviewer secondary coding opens only after the coder's 24-hour recode window closes
- admin may reset/reopen a case at any time and return it to the coder pool
(from
coder_finalizedorreviewer_eligible; NOT fromreviewer_coding_in_progressorreviewer_finalized) - reviewer final COD authority now resolves ahead of coder final COD in the authority service and main submission display path
- additive reviewer final-COD storage exists in
va_reviewer_final_assessments va_final_cod_authorityhas reviewer-pointer support; reviewer submission updates that authority row
Workflow event history:
- every workflow transition is logged to
va_submission_workflow_events - event history is exposed via:
GET /api/v1/workflow/events/<va_sid>— JSON endpointGET /vaform/<va_sid>/workflow_history— HTMX HTML partial
Site PI Behavior
Site PI currently has a reporting-oriented dashboard rather than a full operational workflow.
Current site PI capabilities:
- site-level KPI viewing
- authoritative-coded totals that now include reviewer-finalized cases
- workflow outcome counts, including:
reviewer_eligiblereviewer_finalizedfinalized_upstream_changed
- workflow repair-cycle totals sourced from canonical workflow events,
including:
- admin resets
- upstream-change detection and acceptance
- recode starts and finalizations
- reviewer coding starts and finalizations
- per-submission workflow-cycle rows showing current state, authority source, and event counts
- coder participation reporting, which still intentionally uses coder-owned tables because it measures coder activity rather than final authority
Implementation note:
- Site PI dashboard reporting now resolves through
app/services/sitepi_reporting_service.py - that service is site-scoped through
va_forms.site_idand no longer relies on the earlier mixed site/form assumptions - digitva-0wc stage 5:
get_sitepi_unit_dashboard_data(org_unit_id)gives the same KPIs over the submissions routed into a unit's subtree (no site gate; the roster lists coder unit grants in the subtree; at mostUNIT_SUBMISSION_ROW_LIMITsubmission rows, the cards count all)./sitepi(gaterole_required("site_pi", "project_pi")) offers each held pair, each held In-charge unit, and to aproject_pievery active site of their projects plus the top-level units of a tree project;/sitepi/dataauthorizes every selection withauthz.can(user, SITE_PI_REPORT, target)(values<project>:<site>orunit:<uuid>)
Permissions Model
Current source of truth
Grants are stored in va_user_access_grants: one row per user, role and
scope (global, project, project_site or org_unit), with cadre_id on
unit grants. Scope targets resolve through va_project_master,
va_site_master, va_project_sites and mas_org_unit. Every role resolves
from this table.
Legacy residue: va_users.permission, a NOT NULL JSONB column of
form-centric permissions, still exists but is never read: the attachment
and event-history fall-throughs and VaUsers.has_va_form_access were
deleted once production was found to hold only coder and sitepi keys, which
they already ignored (digitva-d3y5). Account creation (admin, data-manager,
CLI, project user import, seed) writes {}. The legacy shell helpers that
wrote form keys into it (va_user_create, va_user_update, the
va_db_initialise_vausers initialiser) were removed in digitva-a00o; the
test-data seed (app/commands/seed.py) still writes coder keys, which
nothing reads, so no current writer grants access through it.
Current permission helpers
Every role and scope decision runs through app/services/authz/:
can / require (one submission, form, project, unit or intake case),
scope_filter (the same rule as a SQL predicate on VaSubmissions, for
lists), can_grant / grant_list_filter (grant writes and lists),
effective_roles (role gates), reachable_unit_ids (the unit picker, the
device unit list and the area dashboard), coding_gate_waivers and
redacts_pii. All of it is computed from one per-request ResolvedGrants.
role_required decides through effective_roles (digitva-5hmc). A project or
pair grant of coder, coding_tester, reviewer or interviewer opens its gate
only once it reaches an active form (coder and coding_tester on an active
pair), the rule the form-resolved user predicates always had
(Grant.opens_gate); unit grants open regardless.
Every request is decided by authz (digitva-5hmc). Each decision (the
role_required gate, effective_roles, can/require, scope_filter,
reaches, codes_as_tester, reachable_unit_ids, can_grant,
grant_list_filter) marks the request. app/services/authz/consulted.py
wraps every registered view at create_app, except the reviewed
PUBLIC_ENDPOINTS (no session) and SELF_SERVICE_ENDPOINTS (signed in; own
account, reference data, or the area page shell), each with its reason. A
signed-in request whose view finishes without a decision is logged
(endpoint, user id) and, with AUTHZ_ENFORCE_CONSULTED (on in production,
off in development and tests), refused 403. The check sees the view's
outcome before any body is sent, so a streamed response must decide in the
view body. A refused view has already run: enforcement stops exposure, and
tests/test_route_authz_probe.py (every rule and method as a user with no
grant, the body-decided rules as one user per role) keeps writes behind
authz. Anonymous requests are left to the authentication guards.
Resolved grants are cached in Redis across requests (digitva-5hmc). This
reverses the earlier "no cross-request grant cache" design
(.tasks/digitva-0wc-design.md section 9). authz/grant_cache.py keys an
entry by user, a per-user version and a global version, 5-minute TTL, JSON
through the Flask-Caching Redis client (AUTHZ_GRANT_CACHE_ENABLED, off in
tests). Session hooks bump the versions after commit: the user's for any
va_user_access_grants row or a user_status/auth_session_version change;
the global one for a project's status, coding-scope settings or demo switch,
a project-site, an org level or unit, or a form's status. Core statements
call authz.invalidate / invalidate_all (mentor-staff grant withdrawal,
org-unit path rewrites and subtree deactivation). Redis down or an
unparsable entry: the database answers. can still reads the submission's
routing from the database. Only in requests; Celery and CLI resolve fresh.
The user model keeps role predicates (is_coder(), is_reviewer(),
is_site_pi(), is_data_manager(), is_viewer() and friends, used by the
templates and landing pages; is_data_manager, is_site_pi and
is_interview_supervisor read authz.role_flags, effective_roles without
marking the request; none takes a form argument since digitva-a00o) and the
form getters
(get_coder_va_forms(), get_coding_tester_va_forms(), ...) that answer
which forms to offer. Neither decides one submission: form- or
submission-level reach is authz.can / scope_filter.
Current effective model
Grant scope is resolved back to forms and submissions as follows:
- coder access is derived from grant scope, then resolved back to form access through
va_project_sitesandva_forms - site PI access is derived from grant scope, then resolved back to form access through
va_project_sitesandva_forms - reviewer access is derived from grant scope, then resolved back to form access through
va_project_sitesandva_forms - unit-scoped (
org_unit) grants reach only the forms under their subtree (a submission routed there, or the ODK mapping's fallback unit there;VaUsers._get_granted_va_forms), and for projects with an active organization tree the submissions of those forms are then narrowed to the coder's own unit subtree, honouring the project's coding scope level and above-scope mode (authz.ResolvedGrants.codes, applied byscope_filter(CODE)/can(CODE)); a unitcoding_testeradds its whole subtree to the coder pool and its gate waivers apply to its own units only (authz.coding_gate_waivers) - data-management surfaces ask the authorization module (
app/services/authz, digitva-0wc stage 3). A data manager is whoeverauthz.effective_rolessays: adata_managergrant at any scope,site_piat a unit (the In-charge, storable since stage 5) andproject_pion a tree project.VaUsers.is_data_manager()wraps it, so the role gate, navbar,landing_urland the sign-in factor rules follow. Reach:- the grid, exports and filter options:
authz.scope_filter(user, LIST_DATA)(the data-manager lens pluscollaborator/collaborator_pii); a unit grant reaches the submissions routed into its subtree, and the form and filter lists only the forms holding one (data_management_service._dm_visible_forms_condition). A project grant reaches every form of the project, including a deactivated pair's - the data-manager view
/data-management/view/<va_sid>, triage, screening, upstream-change resolution and the ODK edit link:require(TRIAGE); single-submission syncrequire(SYNC_SUBMISSION), re-checked in the Celery task; admin bypasses both (F11) - whole-form sync, its preview and the sync-run history:
SYNC_FORM, a project or pair grant (orproject_pion a tree project); never a unit grant, so a unit data manager or In-charge syncs single submissions only - the MV-backed KPI cards,
/api/v1/analytics/*, the site-grouped stats, COD bucket reports and the/api/v1/analytics/dm-kpi/*panels take the same grants as plain values (data_management_service.dm_grant_scope): project and pair grants, with the unit subtree as a subquery anchored on the granted units (authz.subtree_select;DmScope.sqlrenders the same subquery as text).DmScope: direct (project, site) pair OR submission unit in the subtree; site-keyed aggregates plus a live unit part outside the direct sites; the cache key digests the pairs and the granted unit ids
- the grid, exports and filter options:
- the unrouted queue (
GET /api/v1/data-management/submissions/unrouted): every data manager of a tree project, at any scope, sees the project's unrouted submissions (LIST_UNROUTED; admin's queue is tree projects only), plus the fallback-routed cases they may pin (ROUTE_PIN). Pinning (POST .../submissions/<va_sid>/org-unit) requiresROUTE_PINon the submission and, for a target unit,ROUTE_PINon that unit: a project or pair grant pins anywhere in its project, a unit data manager or In-charge only inside their own subtree; clearing a pin needs only the submission check - the narrowing is applied in the shared availability filter
(
authz.scope_filter(CODE)/scope_filter(REVIEW), used by the pick list, random allocation and the dashboard counts) and per submission when one is opened or allocated (authz.require, the same predicate, in the coding and reviewing services andva_validate_permissions) - projects with no organization tree are unaffected: the filter excludes nothing for them, so the form-and-site model is unchanged
- web intake supervision:
interview_supervisor(unit grants only),data_managergrants, the In-charge (site_piat a unit, its subtree) andproject_pion a tree project (the whole project) supervise the cases in their scope through one rule,app/services/authz/supervision.py(authz.can(user, SUPERVISE_INTAKE, case); the worklist conditionsupervised_case_condition), whichcase_transition_service.is_interview_supervisor_for/supervised_case_conditiondelegate to (no admin bypass). The audit row names the narrowest grant; at equal depthinterview_supervisorandsite_pirank beforedata_managerandproject_pi. Confirming an already coded duplicate needs a data-manager shaped grant (data_manager, In-charge, treeproject_pi). The role gatesVaUsers.is_interview_supervisor()andis_site_pi()(no form argument) wrapauthz.effective_rolessince stage 5 (role_required("interview_supervisor")), the API/api/v1/intake/supervision/ - the death register (
digitva-t6q):POST /api/v1/intake/deathstakes anintervieweror adeath_reporter(authz.actions.DEATH_REGISTERING_ROLES); their reach isweb_intake_service.interviewer_context(user, roles)and_require_scope(..., roles), built from the grants of exactly those roles (so neither role lends the other its cases),register_death_contextbeing the predicate/me/accessactions.register_deathand the app sign-in check read. A reporter'sGET /intake/deathsisreported_deaths_pageand itsPATCH /intake/deaths/<id>the deaths it registered that its unit grant still reaches (_reporter_reaches); every interview route staysrole_required("interviewer") - an unrouted submission of a tree project is codeable by nobody until a data
manager routes it (policy:
docs/policy/organization-model.md)
Viewer route reach and export redaction
Policy: docs/policy/access-control-model.md, collaborator and
collaborator_pii, and "Redaction rules for viewer-reachable surfaces". The
owner decision of 2026-10-02 that viewers may open a single submission
read-only is built by digitva-0wc stage 2 (next subsection).
Opening one submission read-only (digitva-0wc stage 2)
GET /coding/area/<va_sid>admitscollaboratorandcollaborator_piibesides coder, coding tester, reviewer and admin, and requiresauthz.require(user, VIEW, va_sid). It renders actionvaarea: the data manager's categories (show_to_site_pi_datamanager) with no workflow panel and no triage panel, read-only hints, and a back-link to the dashboard the user can open (data_management.dashboardfor a viewer). The data-management grid links a viewer's rows there (viewOnlyinDM_DASHBOARD_CONFIG) and shows no Edit or View Changes button; the navbar shows a "Data" entry; with the Expo export present a viewer lands on/app/like everyone (Data management is a quick action there), andVaUsers.landing_urlfalls back to the dashboard only without the export.- The
vadataandvaareapartial validators requireVIEW. Every write partial inva_form.renderpartialrequires its own action in_require_partial_write:vadmtriageTRIAGE (GET and POST), Step 1, final COD and coder Not Codeable CODE (RECODE forvarecode; the writes themselves, and the same checks again plus the caller's active coding allocation, live incoder_cod_service, which the/api/v1/coding/initial|finalize|not-codeableroutes call too),vareviewformREVIEW,vausernoteonly from a coding or reviewing session. - Redaction: for a viewer
should_redact_piidecides, the section render strips the confirmed PII set plusCSV_EXPORT_OMIT_PAYLOAD_FIELDS(SubmitterName, instance identifiers, narration images and audio), and an unconfirmed PII set withholds the whole payload.collaborator_piisees everything. - Attachments (both routes) and
GET /api/v1/workflow/events/<va_sid>followVIEW. - Opening
vadataorvaareaqueues the open-submission repair only for a user withSYNC_SUBMISSION; a coding session (vacode) still queues it.
The list below is the data-management reach, unchanged by stage 2.
Viewer routes (wired 2026-09-19)
Route wiring (2026-09-19, design record .tasks/viewer-pii-roles.md) put
role_required("collaborator", "collaborator_pii") (both spellings gate on the same check,
VaUsers.is_viewer(), since the two roles have identical reach) into the
read-only data-management surfaces that go through the grid's scope (now
authz.scope_filter(user, LIST_DATA)) and are already redaction-safe:
GET /data-management/(dashboard)GET /api/v1/data-management/submissions,/filter-options,/kpi
That is the complete list — four routes. The KPI shell
/data-management/dashboard was the fifth until digitva-4in (stage 3): it
now admits what the dm-kpi APIs admit, data managers and admin, because
those panels name coders with no redaction path.
Deliberately not widened, and still data_manager/admin only:
- every POST/PUT/DELETE in
app/routes/data_management.pyandapp/routes/api/data_management.py(sync, screening, upstream-change resolution, org-unit correction, user and grant management) — writes GET /data-management/view/<va_sid>(submission detail) — the data manager's rendering with the triage panel and upstream-change actions; viewers open the same submission through/coding/area/<va_sid>insteadGET /data-management/cod-buckets(COD bucket reporting) — the page is only a shell; every value on it is fetched fromapp/routes/api/cod_buckets.py(/schemes,/aggregates,/export.csv), all threedata_manager/adminonly. Granting the page alone would give a viewer a screen that 403s on every fetch. Opening the API is a separate widening —export.csvemits staff identity with no redaction path — so it needs its own review rather than arriving as a side effect of granting the pageGET /data-management/submissions/<va_sid>/odk-edit— an edit-adjacent affordance (a link into ODK Central's own editor), not a readGET /api/v1/data-management/coder-daily-statsand/submissions/export.csv— both carry staff identity (coder_name; the*_byuser ids). Their services now redact for a viewer (table below), but neither route is wired to viewersGET /api/v1/data-management/submissions/unroutedand/project-site-submissions— data-manager actions and data-manager grants only (LIST_UNROUTED/ROUTE_PIN, anddm_grant_scopewithout viewers, respectively)- the whole
dm_kpi/*analytics blueprint, its KPI shell and/api/v1/analytics/*— data-manager grants only, and several surface coder/reviewer performance data by name with no redaction (digitva-4in)
The grid's scope, authz.scope_filter(user, LIST_DATA), resolves
collaborator/collaborator_pii grants at all three scope types exactly
like data_manager: a project or pair grant reaches its forms' submissions,
a unit grant the submissions routed into its subtree and nothing else of the
project. There is one predicate on VaSubmissions, so no caller can forget
to narrow a unit grant (the old dm_scope_filter bridged it to the whole
project and relied on each caller to AND a second condition).
dm_scoped_forms and dm_filter_options list a project or pair grant's
forms, and for a unit grant only the forms holding a submission it may list,
so a project's site roster and ODK ids are not disclosed to a unit grant.
The system-level sync panels of dm_kpi/sync (/status, the D-SH-01 part
of /attachment-health, /smartva-failure-rate) read va_sync_runs /
va_smartva_runs, which have no per-site breakdown, so they are served only
to a holder of a project or pair grant (or project_pi on a tree project,
or admin; digitva-38lp). POST /api/v1/analytics/dm-kpi/refresh, a global
concurrent MV refresh, is limited to 2 per minute.
Redaction coverage of the data-management exports
As of 2026-09-19 these surfaces consult should_redact_pii and blank staff
identity for a viewer who must not see it:
| Surface | Redacted |
|---|---|
dm_submissions_page |
va_data_collector, coded_by |
dm_submissions_export_csv |
the *_by user ids (dm_review_by, initial_assess_by, coder_review_by, reviewer_review_by, final_assess_by, reviewer_final_assess_by) |
dm_coded_cod_snapshot_export_csv |
coder_name, reviewer_name, nqa_name, social_autopsy_name, active_coder_assigned_name, active_reviewer_assigned_name |
dm_coder_daily_statistics |
all rows — see below |
_dm_search_condition |
the collector, coder and reviewer name clauses |
The policy rules "empty the column, never drop it" and "coder daily statistics
return no rows" are what the dm_submissions_export_csv and
dm_coder_daily_statistics rows above implement.
The three SmartVA exports (input, results, likelihoods) emit no
staff-identity column; the input export's payload already passes through
_filter_export_payload.
Closed projects resolve no grant
Every resolver that turns a grant into access requires the grant's project to
have project_status = active, through the shared predicate
app/services/org_grant_service.py::active_project_condition. A project- ,
site- or unit-scoped grant on a closed project resolves to nothing for every
non-admin role, in both mechanisms (org_grant_service and the
VaUsers._get_granted_* helpers the data-management path uses). The grant
rows are untouched, so reopening the project restores access unchanged.
Admins hold a global grant and are not grant-resolved against a project, so
admin access is unaffected. Policy:
docs/policy/access-control-model.md, "Closed Projects".
Coder implies interviewer in a self-coding project
authz.grants._resolve adds one non-virtual interviewer grant per coder
grant on a project whose ProjectSettings.self_coding is set (project flag
self_coding_enabled and web_intake_mode not off), at the coder grant's
scope and gate. Nothing is written: the grant exists only in the resolved
result (and so in the Redis grant cache; a change of either project column bumps
the global version). A mentoring institute member's coder grant implies nothing
(one member_user_ids query, run only when a candidate coder grant exists;
add_member / remove_member drop the member's cached grants).
VaUsers.is_interviewer(), web_intake_service.interviewer_context and the
readiness check read the same result. Policy:
docs/policy/access-control-model.md, "Implied roles".
Language as second filter
Visibility is also filtered by:
current_user.vacode_language
So a coder may have form access but still not see a submission if the narration language does not match profile settings.
Route-Level Validation
The main route guard is:
It validates:
- dashboard access by role
- coding and review action URLs
- submission access based on current user's form permissions and workflow state
Login Flow (two-step, digitva-sn1.1)
Baseline: docs/policy/authentication-factors.md. /vaauth/valogin is the
identifier step followed by the password/passkey page:
- Email step (
va_auth.va_login, GET/POST). The browser solves a local proof-of-work CAPTCHA in a Web Worker (app/static/js/pow_captcha.js+pow_captcha_worker.js, challenge fromva_auth.va_login_captcha_challenge, verified server-side byapp/services/pow_captcha_service.py). On success the server stores a pre-authentication state (session["preauth"]: email, issued-at, safenext) and redirects to step 2. This step never looks upVaUsersand responds identically for a known or unknown email. - Password step (
va_auth.va_login_password, GET/POST,/vaauth/valogin/password). Requires a live, unexpired (5-minute) pre-auth state or redirects back to step 1. Runs the existing checks unchanged (password,user_status, email-verified, maintenance cutoff, safenext) and completes sign-in. The same page shows a "Use a passkey" button above the password form for every email, known or not (app/static/js/webauthn_login.js); the button hides itself whenwindow.PublicKeyCredentialis missing, and a cancelled or failed ceremony shows a neutral message and leaves the password form usable. - Passkey path (
va_auth.va_login_passkey_options/va_auth.va_login_passkey_verify, POST-only JSON, both under/vaauth/valogin/passkey/). Also requires a live pre-auth state...._optionsreturns discoverable-credential authentication options (noallowCredentials, nothing derived from the pre-auth email) built byapp/services/webauthn_service.py, so the response is byte-identical for a known or unknown email...._verifylooks the asserted credential up by its ID, refuses unless it belongs to the pre-auth email's own account, applies the signature-counter policy itself (counter 0/0 is fine; a non-zero stored counter the new value fails to exceed is refused and logged ascounter_regression), atomically bumps the storedsign_count, and completes sign-in through the same shared_complete_login()helper the password path uses. - Session versioning.
VaUsers.auth_session_version(migrationc1d5e9a2f7b4) lets a password reset, and later a factor reset or the break-glass CLI, invalidate every existing session and remember cookie:VaUsers.get_id()returns"<uuid>:<version>"once the version is non-zero, bare"<uuid>"at version 0 so pre-existing sessions keep working; thelogin.user_loaderand every other place that readssession["_user_id"]accept both forms. - Reauthentication window.
_complete_login()stampssession["auth_verified_at"]on every successful sign-in (password or passkey). Registering, renaming or revoking a passkey in Profile (/api/v1/profile/passkeys*) needs that timestamp to be under 10 minutes old, else the route returns401 {"error": "reauth_required"}and the page prompts for the password again (POST /api/v1/profile/reauth). - Post-login nudge. A password sign-in by a user with no registered
passkey sets
session["passkey_nudge"];va_base.htmlthen renders a dismissible banner on the next page linking to the Profile passkey section. Dismissing it (POST /api/v1/profile/dismiss-passkey-nudge) clears the session flag, so it does not reappear for the rest of the session. A successful password or passkey sign-in also setssession["profile_nudge"]when the optional job title, year of birth or sex is missing. The profile banner names the missing fields and links to/profile/; dismissing it (POST /api/v1/profile/nudge/dismiss) clears the flag for the session. A completed profile suppresses the banner, and no profile field is required for access. - No second-factor step. A correct password or verified passkey completes web sign-in for every role. Passkeys remain optional; there is no TOTP, recovery-code prompt, or mandatory factor-setup redirect.
Sign-in by mobile number (digitva-l7c2). Baseline
docs/policy/mobile-sign-in.md. The email step's field is "Email or mobile
number": a value with @ is an email (unchanged); anything else is
canonicalised (user_account_service.canonical_mobile: digits only, a
leading 0 or 91 dropped, exactly ten digits left) and stored in the
pre-auth state as mobile (empty for an invalid value) without a lookup.
The password and passkey steps resolve the account through
_preauth_user(), which matches only va_users.mobile_login (unique), so an
unknown, shared or malformed number gets the same second page and the same
wrong-credentials result. The "verified" check is VaUsers.sign_in_verified:
a verified email, or a redeemed sign-in code (mobile_verified_at).
Generated passwords and onboarding (digitva-l7c2, digitva-kmoy).
Baseline docs/policy/account-onboarding-and-passwords.md. Nobody chooses a
password: there is no chosen-password path left (the Profile change endpoint
PATCH /api/v1/profile/password is gone, PUT /admin/api/users/<id> refuses
a password key with 400, the reset and factor-reset pages have no password
box, and the CLI has no --password). Every password comes from
mobile_sign_in_service.generate_password() (three words from
app/services/mobile_password_words.txt plus four digits, about 45.8 bits,
breach-checked; a breach-check outage raises PasswordGenerationUnavailable
before anything changes) through set_new_password() (sets the hash, bumps
the session version, records password_generated with
detail={"path": "email_verification"|"password_reset"|"profile"|"cli"|"sign_in_code"}).
A password reaches its owner one of two ways:
- By email (
user_account_service.email_new_password): sent synchronously byemail_service.send_password_email(never queued, so it never sits in the Celery broker), templateemails/new_passwordcarrying only the password and the login page address. A send failure raisesPasswordEmailFailed; the caller rolls back so the old password keeps working, and shows a retryable message. Paths: the verification link (va_auth.verify_email; GET shows a "Verify my email" button and changes nothing, the CSRF-protected POST setsemail_verified, recordsemail_verifiedand, only whenhas_usable_password()is false -- no terms accepted (pw_reset_t_and_c) and no code redeemed (mobile_verified_at) -- emails a password; an account that already has one keeps it), the reset link (va_auth.reset_password; GET shows an "Email me a new password" button, POST emails a new one and resetspw_reset_t_and_c; single use via the password-hash fingerprint; refused for an account without an email), and ProfilePOST /api/v1/profile/password/generatefor an account with a verified email (reauth within ten minutes; the response carries no password). Account creation (send_invitation, admin, data manager, mentoring institute, project import, CLI) queues only the verification email. - On screen, once (
no-store): redeeming a sign-in code, and Profile generate for an account without a verified email.flask users reset-passwordandflask users createwithout an email print it once to the operator's terminal.
"Forgot password" (va_auth.forgot_password) takes an email or a mobile
number, finds the account (mobile_login for a number) and queues the reset
link only to a verified email; one message per kind of identifier whether
or not anything matched, so typing an identifier never changes a password.
POST /admin/api/users/<id>/send-password-reset (admin) queues the same
link to a verified email -- never a password.
Sign-in codes. A data manager may issue one only when the person has no
verified email (mobile-only, or an email never verified), and they may manage
every active grant the person holds and none is privileged
(may_issue_code); an admin may issue for any account
(POST /data-management/api/users/<id>/sign-in-code,
POST /admin/api/users/<id>/sign-in-code, and automatically in the create
response for a mobile-only account -- admin, data manager, mentoring
institute staff and project import); shown once; auth_mobile_codes keeps
only an HMAC hash under the factor key, 72-hour expiry, a new code voids the
old, five wrong attempts void it. The person redeems it at
va_auth.va_login_redeem_code (/vaauth/valogin/code, "I have a code";
CAPTCHA, 10 per minute per IP and 10 per hour per identifier, one answer for
a wrong identifier or code) with their email or mobile number; redemption
generates a password, bumps the session version, sets mobile_verified_at
(meaning "has redeemed a code") and shows the password once. Audit events:
mobile_code_issued, mobile_code_voided, mobile_code_redeemed,
password_generated, email_verified (never a code, password or number).
Redeeming a code for an account with an email on file queues
emails/code_redeemed to that address (no password, code or link). The
email_verify token fingerprints the current email, so a link stops working
once the email changes; verify_email refuses an inactive account with the
invalid-link message. Mentoring-institute staff may be created mobile-only by
a platform admin only.
Project user import rows may omit the email: such a row names its person
by mobile number -- an existing account whose mobile_login it is, or (admin
only) a new mobile-only account, which needs a valid number no other account
holds. The apply response returns each new mobile-only account's first code
in sign_in_codes (masked number, no-store, never logged); new email
accounts get the verification email only.
Device sign-in (device_auth_service.open_session, POST
/api/v1/auth/sessions) reads the contract's email field as an email or a
mobile number, resolved like the web login (mobile_login only); an unknown,
shared or malformed number, and a mobile-only account that never redeemed a
code, get the same invalid_credentials; the per-account rate-limit key uses
the canonical number.
8. Optional passkeys and account recovery. Passkey registration, renaming and revocation are recent-reauthentication protected. TOTP, recovery-code prompts and mandatory factor setup are removed from the live login flow. Existing encrypted factor records may remain inert and are not read during sign-in.
No mandatory factor setup. Password and web passkey sign-in remain available to every role.
Admin reset (POST /admin/api/users/<id>/reset-factors, role_required("admin"),
refused for yourself, requires a non-empty reason) and the break-glass
CLI (flask auth reset-factors EMAIL --reason=...) clear active passkeys
and inert legacy factor records, bump the account session version, end
sessions, and record factor_reset. The CLI sends a single-use, timed
recovery link for a verified email; it does not set a password, and the
existing generated password remains valid.
Schema: auth_webauthn_credentials and inert legacy factor records,
auth_security_events (passkey_registered, passkey_renamed,
passkey_revoked, counter_regression, factor_reset,
password_generated, email_verified and the mobile_code_* events) plus
va_users.auth_session_version.
WebAuthn RP ID/origin: config.py's WEBAUTHN_RP_ID / WEBAUTHN_ORIGIN
default to the host / scheme+host of PUBLIC_BASE_URL (via the same
_parse_public_url helper trusted_hosts_for uses), overridable by
environment variable for development; with no PUBLIC_BASE_URL at all they
fall back to localhost / http://localhost:8051, which WebAuthn allows
over plain HTTP.
Admin Runtime Access
An additive admin JSON API now exists under:
/admin/api/...
Current baseline:
adminmay manage all admin API resourcesproject_pimay manage project-site mappings and non-global access grants only inside explicitly granted projects- grant writes go through
authz.can_grant(digitva-0wc stage 6,app/services/authz/grant_writes.py): the data-manager users page (require_dm_scopeandmanage_create_userinapp/routes/data_management.py), the admin panel's non-admin branch (_project_pi_may_writeinapp/routes/admin.py) and the project users import (project_user_import_service.prepare, one decision per distinct role and unit). On a create,can_grantis asked of the raw payload target before the scope is validated (admin._payload_grant_target, digitva-xd1q), so a non-admin gets the same 403 for another project's unit and an unknown one, never the unit or cadre message; after it allows, the cadre check (validate_org_unit_grant) and the mentor guard run unchanged, and every write callsauthz.invalidatefor the grantee - the admin panel's grant list (
/admin/api/access-grants) filters onauthz.grant_list_filter(every grant for an admin; for a project PI the grants it may write, so not otherproject_pigrants); theproject_idnarrowing is allowed for a project where the caller holds a grant-writing grant /data-management/usersserves data managers, In-charges and, in district projects, theproject_pi(all pass the data-manager role gate throughauthz.effective_roles). It writes and listscoder,coding_tester,data_manager,reviewer,interviewer,collaboratorandcollaborator_piionly, admins included; who may write which, where, is the district or site rule indocs/policy/access-control-model.md, "Who creates which grants". The grant list isauthz.grant_list_filter; a non-admin never revokes their owndata_managergrant there (400). Every refusal returns one message, so it does not reveal mentoring institute membership- the page offers a unit scope for district projects, with unit and cadre
pickers fed by
/data-management/api/organization(the units the caller may grant on); creating a user may start with a unit grant - user search: a project or site writer (or admin) searches every account; a unit writer finds only holders of grants it may manage plus the mentoring institute staff of districts it covers (slim rows, no phone)
- exact lookup (
GET /data-management/api/users/lookup?value=, digitva-i0zb): any writer on the page finds an active account by its full email (case-insensitive, at most one) or full mobile number (digits only, a leading0or91before ten digits dropped, on both sides; at most five). Rows carry name, email, status and posts (each active unit grant's unit, level and cadre), no phone, roles or grants. Finding someone does not open their details panel; that still needs a grant the writer may manage. Rate-limited to 10 per minute per user; a 429 under/data-management/api/is JSON - browser-originated mutating admin API requests require the
X-CSRFTokenheader
Important Current-State Limitation
The permission model is built around synthetic app form identity.
This matches the current single-project-first design, but it is not a good long-term fit for:
- reusable sites
- reusable form types
- deployment-based ODK mappings
- project-scoped or site-scoped access models