diff --git a/.github/workflows/type-tests.yml b/.github/workflows/type-tests.yml index 037347b..06ecfec 100644 --- a/.github/workflows/type-tests.yml +++ b/.github/workflows/type-tests.yml @@ -36,3 +36,6 @@ jobs: - name: Type testing with mypy working-directory: v3 # Ensure mypy runs in the correct directory run: uv run mypy --cache-dir /tmp/ --ignore-missing-imports . + - name: Run regression tests + working-directory: v3 + run: uv run python -m unittest discover -s tests -p 'test_*.py' diff --git a/v3/ARCHITECTURE.md b/v3/ARCHITECTURE.md index 2cff9c6..f623857 100644 --- a/v3/ARCHITECTURE.md +++ b/v3/ARCHITECTURE.md @@ -43,7 +43,7 @@ v3/ - **Authentication**: Relies on external ASF authentication via `asfquart.auth`. Requires committer-level access for most operations. - **Templates**: Uses EZT (Easy Template) for server-side rendering. Templates are stored in `templates/` and use `[variable]` syntax for substitution. Includes includes for headers/footers. - **Static Assets**: Served from `static/` directory, including Bootstrap CSS/JS, custom CSS, and JavaScript utilities. -- **Middleware**: Includes session management, CSRF protection (placeholder), and flash messages for user feedback. +- **Middleware**: Includes session management, session-token CSRF protection for mutations, and flash messages for user feedback. Election management is restricted to the election owner; `authz` delegation is not implemented. ### Business Logic Layer (`steve/`) diff --git a/v3/README.md b/v3/README.md index 54eb8c2..cb82475 100644 --- a/v3/README.md +++ b/v3/README.md @@ -209,6 +209,40 @@ A basic example of using the API is available via the See [tests/README.md](tests/README.md) for details on testing the codebase. +## Management and Data Integrity + +Election creation requires an authenticated ASF PMC member whose account is +present in the person database. Management of an existing election is restricted +to its `owner_pid`. The `authz` field is reserved for a future delegation policy +and currently grants no additional management access. + +All `/do-*` mutations require POST and a session CSRF token. HTML forms send +`csrf_token`; JSON requests send `X-CSRFToken`. Opening and closing elections +via GET is not supported. State conflicts return HTTP 409, invalid ballots +return HTTP 400, and ineligible voters receive HTTP 403. A submitted ballot is +atomic across all of its issues. + +Advisory dates entered in the web UI are stored as midnight UTC and displayed in +UTC. They do not schedule opening or closing. Once opened, the opening date is +fixed; once closed, both dates are fixed. Manual opening and closing record the +actual operation time. + +The YAML creation script validates voters without rewriting their IDs and +creates the election, issues, and eligibility records in one transaction. +Missing voters and database failures leave no partially created election. +Timezone-free YAML dates and datetimes are interpreted as UTC; integer +timestamps remain seconds since the Unix epoch. + +Application database connections enforce SQLite foreign keys. Before deploying +this behavior against an existing database, run `PRAGMA foreign_key_check` on a +consistent backup and resolve any reported orphan records. Do not delete +historical voting data to silence integrity errors. + +Use `Election` and `PersonDB.open()` as context managers when their connections +are scoped to an operation. `Election.create(..., db=db)` and `PersonDB(db)` +borrow an existing database connection; the caller owns its transaction and +connection lifetime. + ## Threat Model There are two primary threat vectors that can compromise the cryptographic diff --git a/v3/TODO.md b/v3/TODO.md index dcc1129..16edf10 100644 --- a/v3/TODO.md +++ b/v3/TODO.md @@ -1,52 +1,21 @@ -# TODO: Review and Fixes for v3/server/pages.py +# Remaining v3 Work -Based on a review of `v3/server/pages.py` (and related templates like `voter.ezt`), here are the identified issues, errors, and areas for improvement. These focus on correctness, logic, security, and completeness, while adhering to project conventions (minimal changes, no unsolicited refactors). Prioritized by impact. +## Community Self-Service -## 1. Missing Endpoints for Date Saving (Critical) - RESOLVED -- **Issue**: The `manage.ezt` template includes JavaScript that makes POST requests to `/do-set-open_at/` and `/do-set-close_at/` for auto-saving open/close dates. These endpoints were not defined in `pages.py`, causing the auto-save functionality to fail (likely with 404 errors). -- **Impact**: Users won't be able to save dates via the UI, breaking the intended workflow. -- **Resolution**: Added the two endpoints with a refactored helper function `_set_election_date` to handle common logic (auth, JSON parsing, validation, setting dates, logging, and response). Endpoints now require authentication, validate dates, and log actions. CSRF handling remains a TODO (placeholder token in use). Test for proper date-setting and error handling. Added supporting methods `set_open_at` and `set_close_at` to the Election class in `election.py`, and corresponding cursors in `queries.yaml`. +- Add voter roster management to the web UI, including ASF ID validation and a clear policy for issues added after a roster is configured. +- Add STV creation and editing for candidate labels, names, ASF IDs, and seats. The current web issue creation flow supports YNA only. +- Define delegated management semantics for `authz` before granting access beyond the election owner. +- Expose closed-election tallying through an owner-authorized workflow with execution status, failure reporting, stored results, and controlled downloads. Separate summary results from participation lists and anonymized raw ballots. +- Populate past elections in the voting dashboard and define who can view their results. -## 2. Upcoming Elections Not Populated in `voter_page()` - RESOLVED -- **Issue**: The `voter.ezt` template checks for `[if-any upcoming]` and loops over `upcoming` elections, but `voter_page()` only sets `result.election` (for open elections). `result.upcoming` is never defined, so the "Upcoming Elections" section will always be empty. -- **Impact**: Upcoming elections (e.g., those in 'editable' state with a future open date) won't display, confusing users. -- **Resolution**: Added a new Election class method `upcoming_to_pid` to return editable elections for a given PID with voting eligibility. Added corresponding query `q_upcoming_to_me` in `queries.yaml`. Updated `voter_page()` to fetch and post-process upcoming elections into `result.upcoming`. Updated `voter.ezt` to include a dedicated "Upcoming Elections" section with similar card layout, a "Preview Ballot" link, and subtle visual distinction (lighter background, "Upcoming" badge). No filtering on `open_at` (all editable elections included). +## Operations and Performance -## 3. Hardcoded `vtype` in `do_add_issue_endpoint()` -- **Issue**: `vtype` is hardcoded to `'yna'`, and `kv = None`. The comment mentions handling SEATS for STV, but it's not implemented. If users try to add STV issues, it will fail or behave incorrectly. -- **Impact**: Limits issue types; STV issues can't be added properly. -- **Suggested Fix**: Make `vtype` dynamic (e.g., from form data). For STV, parse `seats` from the form and set `kv = {'seats': int(form.seats)}` or similar. Update the template's form to include a vtype selector and STV-specific fields. +- Define production person-directory refresh and how identity changes interact with frozen election data. +- Measure synchronous SQLite and cryptographic work in HTTP handlers before choosing an offloading strategy; keep database connections in their owning thread. +- Resolve the advertised open-election tally flag: the underlying tally currently requires a closed election. Do not bypass that state restriction as a tooling cleanup. +- Document the complete service deployment, database backup and restore procedure, and released-version pinning with Infra. -## 4. Debug Prints in Endpoints -- **Issue**: `print('FORM:', form)` in `do_add_issue_endpoint()` and `do_edit_issue_endpoint()` is leftover debug code. -- **Impact**: Clutters logs/output; not production-ready. -- **Suggested Fix**: Remove these `print` statements. +## Validation -## 5. Temporary `issue_count` Hack in `postprocess_election()` -- **Issue**: `if 'issue_count' not in e: e.issue_count = 5` is a hardcoded placeholder. -- **Impact**: Incorrect counts; should be removed once the query is fixed. -- **Suggested Fix**: Ensure the Election query/class includes `issue_count` properly, then remove this. - -## 6. CSRF Token Placeholder -- **Issue**: `basic.csrf_token = 'placeholder'` is not secure. -- **Impact**: CSRF protection is bypassed. -- **Suggested Fix**: Implement real CSRF tokens (e.g., generate a random token per session and verify in POST endpoints). - -## 7. Other Minor Notes -- **Authz Checks**: Many endpoints have `### check authz` comments but no implementation. If authorization beyond basic auth is needed, add it (e.g., ensure the user owns the election). -- **Error Handling**: Endpoints like `do_open_endpoint()` assume success; add try/except for potential Election class errors. -- **Logging**: Consistent and useful; no issues. -- **Imports/Constants**: All seem correct and follow conventions. -- **No Obvious Syntax/Logic Errors**: The code parses and runs logically, but the above are functional gaps. - -If you'd like to implement any of these fixes (e.g., add the date endpoints or fix upcoming elections), provide confirmation and details. Let me know if you have more context or want to check specific sections! - -## 8. CSRF Checking -- **Issue**: CSRF tokens are placeholders and not validated in POST endpoints. -- **Impact**: Vulnerable to CSRF attacks. -- **Suggested Fix**: Implement a decorator to check CSRF tokens on POST endpoints (e.g., compare form/session token). Generate real tokens per session. - -## 9. Error Handling in `submitFormWithLoading` -- **Issue**: If the server returns an error during form submission via `submitFormWithLoading`, the page doesn't reload, and the button stays disabled, potentially confusing users. -- **Impact**: Poor UX on submission failures. -- **Suggested Fix**: Investigate and add client-side error handling (e.g., re-enable button on failure, show error message). +- Run historical STV comparisons when the private meeting fixtures are available. +- Extend boundary tests alongside new self-service features; avoid replacing behavior checks with coverage targets. diff --git a/v3/docs/schema.md b/v3/docs/schema.md index 0de9ec0..1844992 100644 --- a/v3/docs/schema.md +++ b/v3/docs/schema.md @@ -21,7 +21,7 @@ Stores metadata for each election. - `eid` (TEXT, PRIMARY KEY): A 10-character hexadecimal election ID (e.g., "1a2b3c4d5e"). Not auto-incremented to prevent URL deduction. Must match the pattern `[0-9a-f]{10}`. - `title` (TEXT, NOT NULL): The election’s title. - `owner_pid` (TEXT, NOT NULL): The ID of the person who created/owns the election. References `person(pid)`. - - `authz` (TEXT): Specifies an LDAP group (e.g., group name or identifier) allowed to edit the election. If `NULL`, only `owner_pid` can edit. The exact format is TBD but will likely map to LDAP-based persons. + - `authz` (TEXT): Reserved for delegated management authorization. The format and enforcement policy are not implemented; currently only `owner_pid` can manage an election, regardless of this field's value. - `salt` (BLOB): A 16-byte salt generated using `secrets.token_bytes()` in Python, set when the election is opened. `NULL` if not opened. - `opened_key` (BLOB): A 32-byte hash (using Argon2) derived from election data to detect tampering, set when the election is opened. `NULL` if not opened. - `closed` (INTEGER): Indicates election status: `NULL` or `0` for not closed, `1` for closed (implies opened). Must be `NULL`, `0`, or `1`. @@ -108,6 +108,6 @@ Records votes cast for issues, supporting re-voting. - The owner (`owner_pid`) can monitor and edit the election at all stages, including viewing voter eligibility and vote counts. ## Notes -- **Authorization**: The `authz` field’s format is TBD but will likely use LDAP group identifiers to define editing permissions. Validation occurs outside the schema (e.g., via LDAP integration). +- **Authorization**: Management currently checks `owner_pid` in the web application. The `authz` field does not grant access until a delegation policy is defined and implemented. - **Query Optimization**: The schema references a `queries.yaml` file, which maps query names to SQL statements for all database interactions. This file provides insight into common operations (e.g., vote tallying, eligibility checks). Specific queries are not included here but can be analyzed for index optimization. - **Future Changes**: The `ciphertext` encryption will transition from Fernet to XChaCha20-Poly1305. Additional `issue` types may be added beyond `yna` and `stv`. diff --git a/v3/queries.yaml b/v3/queries.yaml index ae72220..301ca9c 100644 --- a/v3/queries.yaml +++ b/v3/queries.yaml @@ -132,7 +132,8 @@ election: # Which elections were created by PID? q_owned: | SELECT eid, title, authz, closed, open_at, close_at, - owner_pid, (opened_key IS NOT NULL) AS is_opened + owner_pid, (opened_key IS NOT NULL) AS is_opened, + (SELECT COUNT(*) FROM issue i WHERE i.eid = election.eid) AS issue_count FROM election WHERE owner_pid = ? # Returns all editable elections for voting by the specified person (PID). diff --git a/v3/server/bin/create-election.py b/v3/server/bin/create-election.py index 5656a63..2f1f16c 100755 --- a/v3/server/bin/create-election.py +++ b/v3/server/bin/create-election.py @@ -26,10 +26,10 @@ import datetime import pathlib import logging +from contextlib import closing import yaml import steve.election -import steve.persondb import steve.vtypes.stv _LOGGER = logging.getLogger(__name__) @@ -42,36 +42,44 @@ def parse_datetime(dt): - """Parse ISO datetime string to Unix timestamp, or return if already int.""" - if not dt: + """Parse timestamps or YAML/ISO dates, interpreting timezone-free values as UTC.""" + if dt is None: return None - if isinstance(dt, int): + if type(dt) is int: return dt if isinstance(dt, str): - dt_obj = datetime.datetime.fromisoformat(dt) - return int(dt_obj.timestamp()) + dt = datetime.datetime.fromisoformat(dt) + if isinstance(dt, datetime.date) and not isinstance(dt, datetime.datetime): + dt = datetime.datetime.combine(dt, datetime.time()) + if isinstance(dt, datetime.datetime): + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + return int(dt.timestamp()) raise ValueError(f'Invalid datetime format: {dt}') def validate_issue(issue): """Validate an issue dict from YAML.""" - if 'vtype' not in issue or issue['vtype'] not in VALID_VTYPES: + if not isinstance(issue, dict): + raise ValueError('Each issue must be a mapping') + if not isinstance(issue.get('vtype'), str) or issue['vtype'] not in VALID_VTYPES: raise ValueError(f'Invalid vtype: {issue.get("vtype")}') + if not isinstance(issue.get('title'), str) or not issue['title'].strip(): + raise ValueError('Each issue must have a title') + if issue.get('description') is not None and not isinstance( + issue['description'], str + ): + raise ValueError('Issue description must be a string') if issue['vtype'] == 'stv': - kv = issue.get('kv', {}) - if not all(k in kv for k in ['version', 'labelmap', 'seats']): - raise ValueError( - 'STV issue missing required kv fields: version, labelmap, seats' - ) - if not isinstance(kv['seats'], int) or kv['seats'] <= 0: - raise ValueError('STV seats must be a positive integer') - steve.vtypes.stv.get_candidates(kv) + steve.vtypes.stv.validate_configuration(issue.get('kv')) return issue def main(yaml_file): - with open(yaml_file, 'r') as f: + with open(yaml_file, encoding='utf-8') as f: data = yaml.safe_load(f) + if not isinstance(data, dict) or not isinstance(data.get('election'), dict): + raise ValueError('YAML must contain an election mapping') # Extract election data election_data = data.get('election', {}) @@ -81,74 +89,54 @@ def main(yaml_file): open_at = parse_datetime(election_data.get('open_at')) close_at = parse_datetime(election_data.get('close_at')) - if not title or not owner_pid: + if ( + not isinstance(title, str) + or not title.strip() + or not isinstance(owner_pid, str) + or not owner_pid.strip() + ): raise ValueError('Election must have title and owner_pid') + if authz is not None and not isinstance(authz, str): + raise ValueError('authz must be a string') # Extract issues issues = data.get('issues', []) + if not isinstance(issues, list): + raise ValueError('issues must be a list') for issue in issues: validate_issue(issue) # Extract record: list of pid values for eligible voters record = data.get('record', []) - if not isinstance(record, list): + if not isinstance(record, list) or any(not isinstance(pid, str) for pid in record): raise ValueError('record must be a list of pid values') - - # TODO: Re-enable transactions for safety once database setup allows. - # pdb = steve.persondb.PersonDB(DB_FNAME) - # pdb.db.conn.execute('BEGIN TRANSACTION') - - try: - # Create election + if len(set(record)) != len(record): + raise ValueError('record contains duplicate pid values') + + db = steve.election.Election.open_database(DB_FNAME) + with closing(db.conn), db.conn: + db.conn.execute('BEGIN IMMEDIATE') + for pid in {owner_pid, *record}: + if not db.conn.execute( + 'SELECT 1 FROM person WHERE pid = ?', (pid,) + ).fetchone(): + raise ValueError(f'PID {pid} not found in person database') election = steve.election.Election.create( - DB_FNAME, title, owner_pid, authz, open_at, close_at - ) - _LOGGER.info( - f'Created election[E:{election.eid}]: "{title}" by owner "{owner_pid}"' + DB_FNAME, title, owner_pid, authz, open_at, close_at, db=db ) - - # Add issues for issue_data in issues: - iid = election.add_issue( + election.add_issue( issue_data['title'], issue_data.get('description'), issue_data['vtype'], issue_data.get('kv') if issue_data['vtype'] == 'stv' else None, ) - _LOGGER.info(f'Added issue[I:{iid}] to election[E:{election.eid}]') - - # Open a PersonDB using the existing DB from the Election - pdb = steve.persondb.PersonDB(election.db) - - # HACK: Opened PDB using existing DB; lacks PersonDB cursors. - # q_person: SELECT * FROM person ORDER BY pid - pdb.q_person = pdb.db.cursor_for('SELECT * FROM person ORDER BY pid') - - # Get all persons for validation - all_persons = pdb.list_persons() - all_pids = {person.pid for person in all_persons} - - # Temporary hack: Map old PIDs to newer equivalents for testing. - # TODO: Remove once PID data is fully migrated. - _REMAP = { - 'iroh': 'wells', - } - - # Validate and add voters from record for pid in record: - pid = _REMAP.get(pid, pid) # Apply remapping if needed - if pid not in all_pids: - raise ValueError(f'PID {pid} from record not found in person database') election.add_voter(pid) - _LOGGER.info(f'Added {len(record)} voters to election[E:{election.eid}]') - - # pdb.db.conn.execute('COMMIT') - _LOGGER.info(f'Election[E:{election.eid}] fully created from {yaml_file}') - - except Exception as e: - # pdb.db.conn.execute('ROLLBACK') - _LOGGER.error(f'Failed to create election from {yaml_file}: {e}') - raise + _LOGGER.info( + f'Created election[E:{election.eid}] with {len(record)} eligible voters' + ) + return election.eid if __name__ == '__main__': diff --git a/v3/server/bin/tally.py b/v3/server/bin/tally.py index 0aeb66f..723cd26 100755 --- a/v3/server/bin/tally.py +++ b/v3/server/bin/tally.py @@ -23,17 +23,13 @@ import logging import json import sys +from contextlib import closing import steve.election import steve.persondb from easydict import EasyDict as edict -try: - import pbar -except ImportError: - pbar = None - _LOGGER = logging.getLogger(__name__) THIS_DIR = pathlib.Path(__file__).resolve().parent @@ -56,22 +52,22 @@ def list_elections(db_fname, spy_on_open): elections = [] for eid in eids: - election = steve.election.Election(db_fname, eid) - metadata = election.get_metadata() - # Fetch issue count using existing list_issues method - issue_count = len(election.list_issues()) - # Fetch person count using existing get_voters_for_email method - person_count = len(election.get_voters_for_email()) - elections.append( - edict( - eid=eid, - title=metadata.title, - close_at=metadata.close_at, - state=metadata.state, - issue_count=issue_count, - person_count=person_count, + with steve.election.Election(db_fname, eid) as election: + metadata = election.get_metadata() + # Fetch issue count using existing list_issues method + issue_count = len(election.list_issues()) + # Fetch person count using existing get_voters_for_email method + person_count = len(election.get_voters_for_email()) + elections.append( + edict( + eid=eid, + title=metadata.title, + close_at=metadata.close_at, + state=metadata.state, + issue_count=issue_count, + person_count=person_count, + ) ) - ) # Sort by close_at descending (most recent first) elections.sort(key=lambda x: x.close_at or 0, reverse=True) @@ -147,8 +143,8 @@ def tally_election(election, issue_id, output_format): 'supporting_data': tally_result[1], } all_voters.update(issue_voters) - except Exception as e: - print(f'Error tallying issue {issue.iid}: {e}') + except Exception: + _LOGGER.exception('Error tallying issue %s', issue.iid) raise # Fail hard if output_format == 'json': @@ -176,11 +172,11 @@ def main(spy_on_open, election_id, issue_id, db_fname, output_format): """ if issue_id: db = steve.election.Election.open_database(db_fname) - issue = db.q_get_issue.first_row(issue_id) - if not issue: - raise steve.election.IssueNotFound(issue_id) - election_id = issue.eid - db.conn.close() + with closing(db.conn): + issue = db.q_get_issue.first_row(issue_id) + if not issue: + raise steve.election.IssueNotFound(issue_id) + election_id = issue.eid elif not election_id: elections = list_elections(db_fname, spy_on_open) election_id = select_election(elections) @@ -188,16 +184,16 @@ def main(spy_on_open, election_id, issue_id, db_fname, output_format): print('No election selected. Exiting.') return - election = steve.election.Election(db_fname, election_id) - - # Check for tampering - pdb = steve.persondb.PersonDB.open(db_fname) - if election.is_tampered(pdb): - print(f'Error: Election {election_id} has been tampered with. Cannot proceed.') - sys.exit(1) - - # Proceed with tally - tally_election(election, issue_id, output_format) + with ( + steve.election.Election(db_fname, election_id) as election, + steve.persondb.PersonDB.open(db_fname) as pdb, + ): + if election.is_tampered(pdb): + _LOGGER.error( + 'Election %s has been tampered with; cannot tally', election_id + ) + raise SystemExit(1) + tally_election(election, issue_id, output_format) if __name__ == '__main__': diff --git a/v3/server/pages.py b/v3/server/pages.py index 0b9a977..d6d044a 100644 --- a/v3/server/pages.py +++ b/v3/server/pages.py @@ -25,6 +25,8 @@ import functools import logging import random +import secrets +from contextlib import closing from easydict import EasyDict as edict import asfpy.stopwatch @@ -49,7 +51,7 @@ # Formatted values to inject into templates. FMT_DATE = '%b %d' -FMT_DATE_FULL = '%Y-%m-%d %H:%M' +FMT_DATE_FULL = '%Y-%m-%d %H:%M UTC' SOON_1HOUR = 60 * 60 SOON_CUTOFF = 48 * SOON_1HOUR # 48 hours, in seconds @@ -62,7 +64,7 @@ def rewrite_description(issue): """Rewrite issue description: wrap in
 and convert doc:filename to links."""
     import re
 
-    desc = issue.description
+    desc = issue.description or ''
 
     # Replace doc:filename with  link
     def repl(match):
@@ -103,34 +105,73 @@ async def basic_info():
             email=None,
         )
 
-    ### generate a real token and store in the session
-    basic.csrf_token = 'placeholder'
+    basic.csrf_token = quart.session.setdefault('csrf_token', secrets.token_urlsafe(32))
 
     return basic
 
 
+@APP.before_request
+async def check_csrf():
+    """Require the session's token on application mutations, including JSON requests."""
+    if quart.request.method != 'POST' or not quart.request.path.startswith('/do-'):
+        return
+    expected = quart.session.get('csrf_token')
+    supplied = quart.request.headers.get('X-CSRFToken')
+    if supplied is None:
+        supplied = (await quart.request.form).get('csrf_token')
+    if (
+        not isinstance(expected, str)
+        or not isinstance(supplied, str)
+        or not secrets.compare_digest(expected.encode(), supplied.encode())
+    ):
+        quart.abort(400, 'Invalid CSRF token')
+
+
+def require_election_owner(func):
+    """Limit management to the owner until delegated authz has a defined contract."""
+
+    @functools.wraps(func)
+    async def checked(election, *args, **kwargs):
+        session = await asfquart.session.read()
+        if not session or session['uid'] != election.get_metadata().owner_pid:
+            quart.abort(403)
+        return await func(election, *args, **kwargs)
+
+    return checked
+
+
 async def _set_election_date(election, field):
     """Helper to set open_at or close_at on an election, with validation and logging."""
     result = await basic_info()
-    ### check authz
     data = await quart.request.get_json()
-    date_str = data.get('date')
-    if not date_str:
+    if not isinstance(data, dict) or not isinstance(data.get('date'), str):
         quart.abort(400, 'Missing date')
+    date_str = data['date']
 
     # Validate date (basic check)
     try:
-        dt = datetime.datetime.fromisoformat(date_str).date()
+        date = datetime.date.fromisoformat(date_str)
     except ValueError:
         quart.abort(400, 'Invalid date format')
+    timestamp = int(
+        datetime.datetime.combine(
+            date, datetime.time(), datetime.timezone.utc
+        ).timestamp()
+    )
 
     # Set the date on the election (field is 'open_at' or 'close_at')
-    if field == 'open_at':
-        election.set_open_at(dt)
-    elif field == 'close_at':
-        election.set_close_at(dt)
-    else:
-        quart.abort(400, 'Invalid field')
+    with election.transaction():
+        state = election.get_state()
+        if state == election.S_CLOSED or (
+            field == 'open_at' and state != election.S_EDITABLE
+        ):
+            quart.abort(409, 'This election date can no longer be edited')
+        if field == 'open_at':
+            election.set_open_at(timestamp)
+        elif field == 'close_at':
+            election.set_close_at(timestamp)
+        else:
+            quart.abort(400, 'Invalid field')
 
     _LOGGER.info(
         f'User[U:{result.uid}] set {field} for election[E:{election.eid}] to {date_str}'
@@ -202,9 +243,10 @@ def load_election(func):
     """Decorator to load/pass-argument an Election from EID."""
 
     @functools.wraps(func)
-    async def loader(eid):
+    async def loader(eid, *args, **kwargs):
         try:
-            e = steve.election.Election(DB_FNAME, eid)
+            with steve.election.Election(DB_FNAME, eid) as election:
+                return await func(election, *args, **kwargs)
         except steve.election.ElectionNotFound:
             result = await basic_info()
             result.title = 'Unknown Election'
@@ -213,11 +255,8 @@ async def loader(eid):
             raise_404(T_BAD_EID, result)
             # NOTREACHED
 
-        _LOGGER.debug(f'Loaded: {e}')
-
-        ### check authz
-
-        return await func(e)
+        except steve.election.ElectionBadState:
+            quart.abort(409, 'The election is not in the required state')
 
     return loader
 
@@ -225,28 +264,15 @@ async def loader(eid):
 def load_election_issue(func):
     """Decorator to load/pass-argument an Election from EID."""
 
+    @load_election
     @functools.wraps(func)
-    async def loader(eid, iid):
-        try:
-            e = steve.election.Election(DB_FNAME, eid)
-        except steve.election.ElectionNotFound:
-            result = await basic_info()
-            result.title = 'Unknown Election'
-            result.eid = eid
-            # Note: result.uid (and friends) are needed for the navbar.
-            raise_404(T_BAD_EID, result)
-            # NOTREACHED
-
-        _LOGGER.debug(f'Loaded: {e}')
-
-        ### check authz
-
+    async def loader(e, iid):
         try:
             i = e.get_issue(iid)
         except steve.election.IssueNotFound:
             result = await basic_info()
             result.title = 'Unknown Issue'
-            result.eid = eid
+            result.eid = e.eid
             result.iid = iid
             # Note: result.uid (and friends) are needed for the navbar.
             raise_404(T_BAD_IID, result)
@@ -273,16 +299,17 @@ async def vote_on_page(election):
     result.e_title = result.election.title
 
     # Fetch owner name from PersonDB
-    pdb = steve.persondb.PersonDB.open(DB_FNAME)
-    try:
-        owner_person = pdb.get_person(result.election.owner_pid)
-        result.election.owner_name = owner_person[0]  # name
-    except steve.persondb.PersonNotFound:
-        result.election.owner_name = 'unknown'
+    with steve.persondb.PersonDB.open(DB_FNAME) as pdb:
+        try:
+            owner_person = pdb.get_person(result.election.owner_pid)
+            result.election.owner_name = owner_person[0]
+        except steve.persondb.PersonNotFound:
+            result.election.owner_name = 'unknown'
 
     # Check if user can vote in this election (has mayvote entries)
     election.q_find_issues.perform(result.uid, election.eid)
-    if not election.q_find_issues.fetchall():
+    eligible = {row.iid for row in election.q_find_issues.fetchall()}
+    if not eligible:
         result = await basic_info()
         result.title = 'Access Denied'
         result.eid = election.eid
@@ -291,7 +318,7 @@ async def vote_on_page(election):
     # Add more stuff into the Election instance.
     _ = postprocess_election(result.election)
 
-    all_issues = election.list_issues()
+    all_issues = [issue for issue in election.list_issues() if issue.iid in eligible]
 
     # Split into YNA and STV issues, sort each by title
     issues_yna = [i for i in all_issues if i.vtype == 'yna']
@@ -344,28 +371,15 @@ async def admin_page():
 
     result.owned = [postprocess_election(e) for e in owned]
 
-    ### owned.owner_name should be based on OWNER_PID. That might not be
-    ### the "me" because of authz access to manage issues.
-
-    ### should open/keep a PersonDB instance in the APP
-    pdb = steve.persondb.PersonDB.open(DB_FNAME)
-    try:
-        me = pdb.get_person(result.uid)
-    except steve.persondb.PersonNotFound:
-        ### the authn'd committer is not in "person"
-
-        result = await basic_info()
-        result.title = 'Unknown Person'
-        result.pid = result.uid
-        # Note: result.uid (and friends) are needed for the navbar.
-        raise_404(T_BAD_PID, result)
-        # NOTREACHED
-
-    ### the query for OWNED is just for "me", so this is the correct
-    ### name at the moment. When authz kicks in ... Nope.
-    # me is (name, email)
-    for e in result.owned:
-        e.owner_name = me[0]
+    with steve.persondb.PersonDB.open(DB_FNAME) as pdb:
+        try:
+            name, _ = pdb.get_person(result.uid)
+        except steve.persondb.PersonNotFound:
+            result.title = 'Unknown Person'
+            result.pid = result.uid
+            raise_404(T_BAD_PID, result)
+    for owned_election in result.owned:
+        owned_election.owner_name = name
 
     result.len_election = len(election)
     result.len_owned = len(owned)
@@ -376,6 +390,7 @@ async def admin_page():
 @APP.get('/manage/')
 @asfquart.auth.require({R.committer})  ### need general solution
 @load_election
+@require_election_owner
 @APP.use_template(TEMPLATES / 'manage.ezt')
 async def manage_page(election):
     result = await basic_info()
@@ -398,6 +413,7 @@ async def manage_page(election):
 @APP.get('/manage-stv//')
 @asfquart.auth.require({R.committer})  ### need general solution
 @load_election_issue
+@require_election_owner
 @APP.use_template(TEMPLATES / 'manage-stv.ezt')
 async def manage_stv_page(election, issue):
     if issue.vtype != 'stv':
@@ -429,6 +445,7 @@ async def manage_stv_page(election, issue):
 @APP.post('/do-set-open_at/')
 @asfquart.auth.require({R.committer})
 @load_election
+@require_election_owner
 async def do_set_open_at_endpoint(election):
     return await _set_election_date(election, 'open_at')
 
@@ -436,6 +453,7 @@ async def do_set_open_at_endpoint(election):
 @APP.post('/do-set-close_at/')
 @asfquart.auth.require({R.committer})
 @load_election
+@require_election_owner
 async def do_set_close_at_endpoint(election):
     return await _set_election_date(election, 'close_at')
 
@@ -446,42 +464,26 @@ async def do_set_close_at_endpoint(election):
 async def do_vote_endpoint(election):
     result = await basic_info()
 
-    ### check authz
-
-    # Parse the form data
-    form = edict(await quart.request.form)
-    if not form:
-        await flash_danger('No vote data provided.')
-        return quart.redirect(f'/vote-on/{election.eid}', code=303)
-
-    # Extract votes from form (keys like 'vote-')
+    form = await quart.request.form
     votes = {}
     for key, value in form.items():
         if key.startswith('vote-'):
+            if len(form.getlist(key)) != 1:
+                quart.abort(400, 'Multiple votes submitted for one issue')
             iid = key.split('-', 1)[1]
             votes[iid] = value
+    if not votes:
+        quart.abort(400, 'No vote data provided')
+    try:
+        election.add_votes(result.uid, votes)
+    except (ValueError, steve.election.IssueNotFound):
+        quart.abort(400, 'Invalid ballot')
+    except PermissionError:
+        quart.abort(403)
 
-    # Get the list of issues for this election
-    issues = election.list_issues()
-    issue_dict = {i.iid: i for i in issues}
-
-    # Process each vote
-    for iid, votestring in votes.items():
-        if iid not in issue_dict:
-            await flash_danger(f'Invalid issue ID: {iid}')
-            return quart.redirect(f'/vote-on/{election.eid}', code=303)
-
-        try:
-            election.add_vote(result.uid, iid, votestring)
-            _LOGGER.info(
-                f'User[U:{result.uid}] voted on issue[I:{iid}] in election[E:{election.eid}]'
-            )
-        except Exception as e:
-            _LOGGER.error(
-                f'Error adding vote for user[U:{result.uid}] on issue[I:{iid}]: {e}'
-            )
-            await flash_danger(f'Error submitting vote for issue {iid}.')
-            return quart.redirect(f'/vote-on/{election.eid}', code=303)
+    _LOGGER.info(
+        f'User[U:{result.uid}] submitted a ballot in election[E:{election.eid}]'
+    )
 
     await flash_success('Votes submitted successfully!')
     return quart.redirect('/voter', code=303)
@@ -494,36 +496,32 @@ async def do_create_endpoint():
 
     result = await basic_info()
 
-    ### check authz
-
-    form = edict(await quart.request.form)
-
-    # Create the Election.
-    election = steve.election.Election.create(DB_FNAME, form.title, result.uid)
-
-    _LOGGER.info(
-        f'User[U:{result.uid}] created election[E:{election.eid}];'
-        f' title: "{form.title}"'
-    )
-    await flash_success(f'Created election: {form.title}')
-
-    # Go to the management page for the new Election.
-    return quart.redirect(f'/manage/{election.eid}', code=303)
+    form = await quart.request.form
+    title = form.get('title', '').strip()
+    if not title:
+        quart.abort(400, 'Election title is required')
+    with steve.persondb.PersonDB.open(DB_FNAME) as pdb:
+        try:
+            pdb.get_person(result.uid)
+        except steve.persondb.PersonNotFound:
+            quart.abort(
+                409, 'Your account has not been loaded into the voter directory'
+            )
+    with steve.election.Election.create(DB_FNAME, title, result.uid) as election:
+        _LOGGER.info(f'User[U:{result.uid}] created election[E:{election.eid}]')
+        await flash_success(f'Created election: {title}')
+        return quart.redirect(f'/manage/{election.eid}', code=303)
 
 
-@APP.get('/do-open/')
+@APP.post('/do-open/')
 @asfquart.auth.require({R.committer})  ### need general solution
 @load_election
+@require_election_owner
 async def do_open_endpoint(election):
     result = await basic_info()
 
-    ### check authz
-
-    ### should open/keep a PersonDB instance in the APP
-    pdb = steve.persondb.PersonDB.open(DB_FNAME)
-
-    # Open the Election.
-    election.open(pdb)
+    with steve.persondb.PersonDB.open(DB_FNAME) as pdb:
+        election.open(pdb)
 
     _LOGGER.info(f'User[U:{result.uid}] opened election[E:{election.eid}]')
 
@@ -534,14 +532,13 @@ async def do_open_endpoint(election):
     return quart.redirect(f'/manage/{election.eid}', code=303)
 
 
-@APP.get('/do-close/')
+@APP.post('/do-close/')
 @asfquart.auth.require({R.committer})  ### need general solution
 @load_election
+@require_election_owner
 async def do_close_endpoint(election):
     result = await basic_info()
 
-    ### check authz
-
     # Close the Election.
     election.close()
 
@@ -557,26 +554,21 @@ async def do_close_endpoint(election):
 @APP.post('/do-add-issue/')
 @asfquart.auth.require({R.committer})  ### need general solution
 @load_election
+@require_election_owner
 async def do_add_issue_endpoint(election):
     result = await basic_info()
 
-    ### check authz
-
-    form = edict(await quart.request.form)
-    print('FORM:', form)
-
-    ### do better with these
-    vtype = 'yna'
-    kv = None
-    ### for STV, there is a SEATS form parameter. Create empty LABELMAP.
-
-    iid = election.add_issue(form.title, form.description, vtype, kv)
+    form = await quart.request.form
+    title = form.get('title', '').strip()
+    if not title:
+        quart.abort(400, 'Issue title is required')
+    iid = election.add_issue(title, form.get('description', ''), 'yna', None)
 
     _LOGGER.info(
         f'User[U:{result.uid}] added issue[I:{iid}] to election[E:{election.eid}]'
     )
 
-    await flash_success(f'Issue "{form.title}" has been added.')
+    await flash_success(f'Issue "{title}" has been added.')
 
     # Return to the management page for this Election.
     return quart.redirect(f'/manage/{election.eid}', code=303)
@@ -585,17 +577,20 @@ async def do_add_issue_endpoint(election):
 @APP.post('/do-edit-issue//')
 @asfquart.auth.require({R.committer})  ### need general solution
 @load_election_issue
+@require_election_owner
 async def do_edit_issue_endpoint(election, issue):
     result = await basic_info()
 
-    ### check authz
-
-    form = edict(await quart.request.form)
-    print('FORM:', form)
+    form = await quart.request.form
+    title = form.get('title', '').strip()
+    if not title:
+        quart.abort(400, 'Issue title is required')
 
     # Update the title/description.
     ### for now, no way to update the vtype or KV pairs.
-    election.edit_issue(issue.iid, form.title, form.description, issue.vtype, issue.kv)
+    election.edit_issue(
+        issue.iid, title, form.get('description', ''), issue.vtype, issue.kv
+    )
 
     _LOGGER.info(
         f'User[U:{result.uid}] edited issue[I:{issue.iid}]'
@@ -603,7 +598,7 @@ async def do_edit_issue_endpoint(election, issue):
     )
 
     # Use the new TITLE for this.
-    await flash_success(f'Issue "{form.title}" has been updated.')
+    await flash_success(f'Issue "{title}" has been updated.')
 
     # Return to the management page for this Election.
     return quart.redirect(f'/manage/{election.eid}', code=303)
@@ -612,11 +607,10 @@ async def do_edit_issue_endpoint(election, issue):
 @APP.post('/do-delete-issue//')
 @asfquart.auth.require({R.committer})  ### need general solution
 @load_election_issue
+@require_election_owner
 async def do_delete_issue_endpoint(election, issue):
     result = await basic_info()
 
-    ### check authz
-
     # Issue exists, and was loaded. No errors to handle?
     election.delete_issue(issue.iid)
 
@@ -667,7 +661,8 @@ async def serve_doc(iid, docname):
     result = await basic_info()  # get PID  ### grr: called uid
 
     db = steve.election.Election.open_database(DB_FNAME)
-    row = db.q_get_mayvote.first_row(result.uid, iid)
+    with closing(db.conn):
+        row = db.q_get_mayvote.first_row(result.uid, iid)
     if not row:
         # Nothing fancy. Just pretend the file does not exist
         quart.abort(404)
@@ -694,7 +689,7 @@ def format_datetime(dt):
     "Format a datetime as absolute or relative."
 
     # Carry through an absent datetime.
-    if not dt:
+    if dt is None:
         return None
 
     delta = dt.timestamp() - datetime.datetime.now().timestamp()
@@ -729,10 +724,18 @@ def postprocess_election(e):
     # this is called "editable" (S_EDITABLE)
 
     # Format dates, if present.
-    dt_open = e.open_at and datetime.datetime.fromtimestamp(e.open_at)
+    dt_open = (
+        datetime.datetime.fromtimestamp(e.open_at, datetime.timezone.utc)
+        if e.open_at is not None
+        else None
+    )
     e.fmt_open_at = format_datetime(dt_open)
     e.fmt_open_at_full = dt_open and dt_open.strftime(FMT_DATE_FULL)
-    dt_close = e.close_at and datetime.datetime.fromtimestamp(e.close_at)
+    dt_close = (
+        datetime.datetime.fromtimestamp(e.close_at, datetime.timezone.utc)
+        if e.close_at is not None
+        else None
+    )
     e.fmt_close_at = format_datetime(dt_close)
     e.fmt_close_at_full = dt_close and dt_close.strftime(FMT_DATE_FULL)
 
@@ -740,10 +743,6 @@ def postprocess_election(e):
     e.fmt_open_at_iso = dt_open.date().isoformat() if dt_open else None
     e.fmt_close_at_iso = dt_close.date().isoformat() if dt_close else None
 
-    ### temporary. need to adjust input query.
-    if 'issue_count' not in e:
-        e.issue_count = 5  ### arbitrary. just provide a value
-
     ### need to figure this out later.
     e.has_voted = None  # EZT False
 
diff --git a/v3/server/templates/manage-stv.ezt b/v3/server/templates/manage-stv.ezt
index 2a0821b..1cad4cf 100644
--- a/v3/server/templates/manage-stv.ezt
+++ b/v3/server/templates/manage-stv.ezt
@@ -42,7 +42,10 @@
                 
                 
             
         
@@ -61,7 +64,10 @@
                 
                 
             
         
@@ -197,7 +203,6 @@
     const confirmOpen = document.getElementById('confirm-open');
     confirmOpen.addEventListener('click', () => {
         openModal.hide();
-        window.location.href = '/do-open/[eid]';
     });
 
     [is e_state "open"]
@@ -209,7 +214,6 @@
     const confirmClose = document.getElementById('confirm-close');
     confirmClose.addEventListener('click', () => {
         closeModal.hide();
-        window.location.href = '/do-close/[eid]';
     });
 
     // Toggle individual description
diff --git a/v3/server/templates/manage.ezt b/v3/server/templates/manage.ezt
index c9864b4..50ead2a 100644
--- a/v3/server/templates/manage.ezt
+++ b/v3/server/templates/manage.ezt
@@ -137,7 +137,10 @@
                 
                 
@@ -159,7 +162,10 @@
                 
                 
@@ -319,7 +325,6 @@
     const confirmOpen = document.getElementById('confirm-open');
     confirmOpen.addEventListener('click', () => {
         openModal.hide();
-        window.location.href = '/do-open/[eid]';
     });
 
     [is e_state "open"]
@@ -331,7 +336,6 @@
     const confirmClose = document.getElementById('confirm-close');
     confirmClose.addEventListener('click', () => {
         closeModal.hide();
-        window.location.href = '/do-close/[eid]';
     });
 
     // Auto-save a date field on change
diff --git a/v3/steve/election.py b/v3/steve/election.py
index 83f0d2f..9240f19 100644
--- a/v3/steve/election.py
+++ b/v3/steve/election.py
@@ -19,6 +19,7 @@
 import json
 import sqlite3
 import pathlib
+from contextlib import contextmanager, closing
 
 import asfpy.db
 from easydict import EasyDict as edict
@@ -40,16 +41,54 @@ class Election:
 
     @staticmethod
     def open_database(db_fname):
-        return asfpy.db.DB(db_fname, yaml_fname=QUERIES, yaml_section='election')
+        db = asfpy.db.DB(db_fname, yaml_fname=QUERIES, yaml_section='election')
+        db.conn.execute('PRAGMA foreign_keys = ON')
+        return db
 
-    def __init__(self, db_fname, eid, op='Opening'):
+    def __init__(self, db_fname, eid, op='Opening', *, db=None):
         _LOGGER.debug(f'{op} election ID "{eid}"')
 
-        self.db = self.open_database(db_fname)
+        self._owns_db = db is None
+        self.db = self.open_database(db_fname) if db is None else db
         self.eid = eid
 
-        if not_found(self.q_check_election, eid):
-            raise ElectionNotFound(eid)
+        try:
+            if not_found(self.q_check_election, eid):
+                raise ElectionNotFound(eid)
+        except Exception:
+            self.close_database()
+            raise
+
+    def close_database(self):
+        """Close an owned connection; leave a borrowed connection to its caller."""
+        if self._owns_db and self.db is not None:
+            self.db.conn.close()
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        self.close_database()
+
+    @contextmanager
+    def transaction(self):
+        """Keep an operation atomic, including inside a caller-owned transaction."""
+        conn = self.db.conn
+        if conn.in_transaction:
+            conn.execute('SAVEPOINT election_operation')
+            try:
+                yield
+            except BaseException:
+                if conn.in_transaction:
+                    conn.execute('ROLLBACK TO SAVEPOINT election_operation')
+                    conn.execute('RELEASE SAVEPOINT election_operation')
+                raise
+            else:
+                conn.execute('RELEASE SAVEPOINT election_operation')
+        else:
+            with conn:
+                conn.execute('BEGIN IMMEDIATE')
+                yield
 
     def __getattr__(self, name):
         "Proxy the cursors."
@@ -58,45 +97,22 @@ def __getattr__(self, name):
     def delete(self):
         "Delete this Election and its Issues and Person/Issue pairs."
 
-        # Can't delete if it has been opened (even if later closed).
-        assert self.is_editable()
-
-        # Normally, we are in auto-commit mode. Switch to transactional.
-        self.db.conn.execute('BEGIN TRANSACTION')
-
-        # Order these things because of referential integrity.
-
-        # Delete all rows that refer to Issues within this Election.
-        self.c_delete_mayvote.perform(self.eid)
-
-        # Now, delete all the Issues that are part of this Election.
-        self.c_delete_issues.perform(self.eid)
-
-        # Finally, remove the Election itself.
-        self.c_delete_election.perform(self.eid)
-
-        self.db.conn.execute('COMMIT')
-
-        # Disable this instance.
-        self.db.conn.close()
-        self.db = None
+        with self.transaction():
+            self._all_metadata(self.S_EDITABLE)
+            self.c_delete_mayvote.perform(self.eid)
+            self.c_delete_issues.perform(self.eid)
+            self.c_delete_election.perform(self.eid)
+        if not self.db.conn.in_transaction:
+            self.close_database()
 
     def open(self, pdb):
-        # Double-check the Election is in the editing state.
-        assert self.is_editable()
-
-        # Add salts first. If this is gonna fail, then make sure it
-        # happens before we move to the "opened" state.
-        self.add_salts()
-
-        edata = self.gather_election_data(pdb)
-        # print('EDATA:', edata)
-        salt = crypto.gen_salt()
-        opened_key = crypto.gen_opened_key(edata, salt)
-
-        # print('SALT:', salt)
-        # print('KEY:', opened_key)
-        self.c_open.perform(salt, opened_key, self.eid)
+        with self.transaction():
+            self._all_metadata(self.S_EDITABLE)
+            self.add_salts()
+            edata = self.gather_election_data(pdb)
+            salt = crypto.gen_salt()
+            opened_key = crypto.gen_opened_key(edata, salt)
+            self.c_open.perform(salt, opened_key, self.eid)
 
     def gather_election_data(self, pdb):
         "Gather a definition of this election for keying and anti-tamper."
@@ -130,44 +146,18 @@ def gather_election_data(self, pdb):
     def close(self):
         "Close an election."
 
-        # The Election should be open.
-        assert self.is_open()
-
-        # Simple tweak of the metadata to close the Election.
-        self.c_close.perform(self.eid)
+        with self.transaction():
+            self._all_metadata(self.S_OPEN)
+            self.c_close.perform(self.eid)
 
     def add_salts(self):
         "Set the SALT column in the MAYVOTE table."
 
-        # The Election should be editable.
-        assert self.is_editable()
-
-        # Use Q_ALL_ISSUES to iterate over all Person/Issue mappings
-        # in this Election (specified by EID).
-
-        # Normally, we are in auto-commit mode. Switch to transactional.
-        self.db.conn.execute('BEGIN TRANSACTION')
-
-        self.q_all_issues.perform(self.eid)
-        for mayvote in self.q_all_issues.fetchall():
-            # MAYVOTE is a 1-tuple: _ROWID_
-            # print('COLUMNS:', dir(mayvote))
-
-            # Use a distinct cursor to insert the SALT value.
-            salt = crypto.gen_salt()
-            self.c_salt_mayvote.perform(salt, mayvote.rowid)
-
-        self.db.conn.execute('COMMIT')
-
-    def _disappeared(self):
-        "The Election disappeared in the database. Disable SELF."
-
-        # Disable this instance.
-        self.db.conn.close()
-        self.db = None
-
-        # The caller may want to inform this EID no longer exists.
-        return ElectionNotFound(self.eid)
+        with self.transaction():
+            self._all_metadata(self.S_EDITABLE)
+            self.q_all_issues.perform(self.eid)
+            for mayvote in self.q_all_issues.fetchall():
+                self.c_salt_mayvote.perform(crypto.gen_salt(), mayvote.rowid)
 
     def _all_metadata(self, required_state=None):
         "INTERNAL ONLY: return all metadata about this Election."
@@ -176,7 +166,7 @@ def _all_metadata(self, required_state=None):
         # API is not for public use.
         md = self.q_metadata.first_row(self.eid)
         if not md:
-            raise self._disappeared()
+            raise ElectionNotFound(self.eid)
 
         if required_state:
             state = self._compute_state(md)
@@ -206,60 +196,64 @@ def get_issue(self, iid):
         "Return TITLE, DESCRIPTION, TYPE, and KV for issue IID."
 
         issue = self.q_get_issue.first_row(iid)
-        if not issue:
+        if not issue or issue.eid != self.eid:
             raise IssueNotFound(iid)
 
-        # NEVER return issue.salt
         return (issue.title, issue.description, issue.type, self.json2kv(issue.kv))
 
     def add_issue(self, title, description, vtype, kv):
         "Add a new issue with a generated unique IID."
-        assert self.is_editable()
-        assert vtype in vtypes.TYPES
-
-        while True:
-            iid = crypto.create_id()
-            try:
-                # Pure INSERT - SALT will be NULL until election opens
-                self.c_add_issue.perform(
-                    iid, self.eid, title, description, vtype, self.kv2json(kv)
-                )
-                break
-            except sqlite3.IntegrityError:
-                _LOGGER.debug('IID conflict(!!) ... trying again.')
-
-        _LOGGER.info(f'Created issue[I:{iid}] in election[E:{self.eid}]')
-
-        return iid
+        with self.transaction():
+            self._all_metadata(self.S_EDITABLE)
+            vtypes.vtype_module(vtype)
+            if vtype == 'stv':
+                vtypes.stv.validate_configuration(kv)
+
+            while True:
+                iid = crypto.create_id()
+                try:
+                    # Pure INSERT - SALT will be NULL until election opens
+                    self.c_add_issue.perform(
+                        iid, self.eid, title, description, vtype, self.kv2json(kv)
+                    )
+                    break
+                except sqlite3.IntegrityError:
+                    if self.q_get_issue.first_row(iid) is None:
+                        raise
+                    _LOGGER.debug('IID conflict ... trying again.')
+
+            _LOGGER.info(f'Created issue[I:{iid}] in election[E:{self.eid}]')
+
+            return iid
 
     def edit_issue(self, iid, title, description, vtype, kv):
         "Edit an existing issue designated by IID."
-        assert self.is_editable()
-        assert vtype in vtypes.TYPES
+        with self.transaction():
+            self._all_metadata(self.S_EDITABLE)
+            self.get_issue(iid)
+            vtypes.vtype_module(vtype)
+            if vtype == 'stv':
+                vtypes.stv.validate_configuration(kv)
 
-        self.c_edit_issue.perform(title, description, vtype, self.kv2json(kv), iid)
+            self.c_edit_issue.perform(title, description, vtype, self.kv2json(kv), iid)
 
-        # If the issue didn't exist, we updated nothing.
-        if self.c_edit_issue.rowcount == 0:
-            raise IssueNotFound(iid)
+            # If the issue didn't exist, we updated nothing.
+            if self.c_edit_issue.rowcount == 0:
+                raise IssueNotFound(iid)
 
-        _LOGGER.info(f'Updated issue[I:{iid}] in election[E:{self.eid}]')
+            _LOGGER.info(f'Updated issue[I:{iid}] in election[E:{self.eid}]')
 
     def delete_issue(self, iid):
         "Delete the Issue designated by IID."
 
-        # Can only delete Issues before the Election is OPEN.
-        assert self.is_editable()
-
-        self.c_delete_issue.perform(iid)
-
-        # If the issue didn't exist, we deleted nothing.
-        if self.c_delete_issue.rowcount == 0:
-            raise IssueNotFound(iid)
-        # else .rowcount == 1
+        with self.transaction():
+            self._all_metadata(self.S_EDITABLE)
+            self.get_issue(iid)
+            self.db.conn.execute('DELETE FROM mayvote WHERE iid = ?', (iid,))
+            self.c_delete_issue.perform(iid)
 
     def list_issues(self):
-        "Return ordered EasyDicgt for all ISSUES."
+        "Return ordered EasyDict for all issues."
 
         def extract_issue(row):
             return edict(
@@ -275,23 +269,41 @@ def extract_issue(row):
 
     def add_voter(self, pid: str, iid: str | None = None) -> None:
         "Add PID (Person) to Issue IID, or to all Issues (None)."
-
-        # This is only allowed while the Election is editable.
-        assert self.is_editable()
-
-        if iid:
-            self.c_add_mayvote.perform(pid, iid)
-        else:
-            self.c_add_mayvote_all.perform(pid, self.eid)
+        with self.transaction():
+            # This is only allowed while the Election is editable.
+            self._all_metadata(self.S_EDITABLE)
+
+            if iid:
+                self.get_issue(iid)
+                self.c_add_mayvote.perform(pid, iid)
+            else:
+                self.c_add_mayvote_all.perform(pid, self.eid)
+
+    def validate_vote(self, pid: str, iid: str, votestring: str):
+        """Validate eligibility and ballot syntax without recording a vote."""
+        if not isinstance(votestring, str):
+            raise ValueError('Vote must be a string')
+        self._all_metadata(self.S_OPEN)
+        _, _, vtype, kv = self.get_issue(iid)
+        mayvote = self.q_get_mayvote.first_row(pid, iid)
+        if mayvote is None:
+            raise PermissionError('Not eligible to vote on this issue')
+        vtypes.vtype_module(vtype).validate_vote(votestring, kv)
 
     def add_vote(self, pid: str, iid: str, votestring: str):
         "Add VOTESTRING as the (latest) vote by PID for IID."
+        self.add_votes(pid, {iid: votestring})
 
-        # The Election should be open.
-        md = self._all_metadata(self.S_OPEN)
-
-        ### validate VOTESTRING for ISSUE.TYPE voting
+    def add_votes(self, pid: str, votes: dict[str, str]):
+        """Record a ballot atomically, rejecting it entirely if any issue is invalid."""
+        with self.transaction():
+            for iid, votestring in votes.items():
+                self.validate_vote(pid, iid, votestring)
+            for iid, votestring in votes.items():
+                self._record_vote(pid, iid, votestring)
 
+    def _record_vote(self, pid, iid, votestring):
+        md = self._all_metadata(self.S_OPEN)
         mayvote = self.q_get_mayvote.first_row(pid, iid)
         vote_token = crypto.gen_vote_token(md.opened_key, pid, iid, mayvote.salt)
 
@@ -318,6 +330,7 @@ def tally_issue(self, iid):
         # md = self._all_metadata()
 
         # Need the issue TYPE
+        self.get_issue(iid)
         issue = self.q_get_issue.first_row(iid)
 
         # Accumulate PID values for each person who voted on IID.
@@ -376,8 +389,6 @@ def has_voted_upon(self, pid):
 
         self.q_find_issues.perform(pid, self.eid)
         for row in self.q_find_issues.fetchall():
-            # print('COLUMNS:', dir(row))
-
             # Query is mayvote.* ... so ROW is: PID, IID, SALT
             vote_token = crypto.gen_vote_token(
                 md.opened_key,
@@ -405,10 +416,6 @@ def is_tampered(self, pdb):
         edata = self.gather_election_data(pdb)
         opened_key = crypto.gen_opened_key(edata, md.salt)
 
-        # print('EDATA:', edata)
-        # print('SALT:', md.salt)
-        # print('KEY:', opened_key)
-
         # The computed key should be unchanged.
         return opened_key != md.opened_key
 
@@ -458,26 +465,45 @@ def json2kv(j):
 
     @classmethod
     def create(
-        cls, db_fname, title, owner_pid, authz=None, open_at=None, close_at=None
+        cls,
+        db_fname,
+        title,
+        owner_pid,
+        authz=None,
+        open_at=None,
+        close_at=None,
+        *,
+        db=None,
     ):
-        ### Open in autocommit??
-        db = cls.open_database(db_fname)
-
-        while True:
-            eid = crypto.create_id()
-            try:
-                db.c_create.perform(eid, title, owner_pid, authz, open_at, close_at)
-                break
-            except sqlite3.IntegrityError:
-                _LOGGER.debug('EID conflict(!!) ... trying again.')
-        _LOGGER.info(f'Created election[E:{eid}]')
-
-        return cls(db_fname, eid, op='Opening NEW')
+        """Create an election, optionally in a caller-owned database transaction."""
+        if not isinstance(title, str) or not title.strip():
+            raise ValueError('Election title is required')
+        owns_db = db is None
+        if owns_db:
+            db = cls.open_database(db_fname)
+        try:
+            while True:
+                eid = crypto.create_id()
+                try:
+                    db.c_create.perform(eid, title, owner_pid, authz, open_at, close_at)
+                    break
+                except sqlite3.IntegrityError:
+                    if not_found(db.q_check_election, eid):
+                        raise
+                    _LOGGER.debug('EID conflict ... trying again.')
+            election = cls(db_fname, eid, op='Opening NEW', db=db)
+            election._owns_db = owns_db
+            return election
+        except Exception:
+            if owns_db:
+                db.conn.close()
+            raise
 
     @classmethod
     def delete_by_eid(cls, db_fname, eid):
         "Delete the specified Election."
-        cls(db_fname, eid).delete()
+        with cls(db_fname, eid) as election:
+            election.delete()
 
     @classmethod
     def open_to_pid(cls, db_fname, pid):
@@ -486,10 +512,9 @@ def open_to_pid(cls, db_fname, pid):
         db = cls.open_database(db_fname)
 
         # Run the generator to get all rows. Returned as EasyDicts.
-        db.q_open_to_me.perform(
-            pid,
-        )
-        return [row for row in db.q_open_to_me.fetchall()]
+        with closing(db.conn):
+            db.q_open_to_me.perform(pid)
+            return list(db.q_open_to_me.fetchall())
 
     @classmethod
     def owned_elections(cls, db_fname, pid):
@@ -501,10 +526,9 @@ def owned_elections(cls, db_fname, pid):
         #       SALT or OPENED_KEY values.
         #
         # Run the generator to get all rows. Returned as EasyDicts.
-        db.q_owned.perform(
-            pid,
-        )
-        return [row for row in db.q_owned.fetchall()]
+        with closing(db.conn):
+            db.q_owned.perform(pid)
+            return list(db.q_owned.fetchall())
 
     @classmethod
     def upcoming_to_pid(cls, db_fname, pid):
@@ -513,25 +537,22 @@ def upcoming_to_pid(cls, db_fname, pid):
         db = cls.open_database(db_fname)
 
         # Run the generator to get all rows. Returned as EasyDicts.
-        db.q_upcoming_to_me.perform(
-            pid,
-        )
-        return [row for row in db.q_upcoming_to_me.fetchall()]
+        with closing(db.conn):
+            db.q_upcoming_to_me.perform(pid)
+            return list(db.q_upcoming_to_me.fetchall())
 
     @classmethod
     def list_closed_election_ids(cls, db_fname, include_open=False):
         "Return a list of Election IDs for closed elections, optionally including open ones."
         db = cls.open_database(db_fname)
 
-        eids = []
-        db.q_closed_election_ids.perform()
-        eids.extend(row.eid for row in db.q_closed_election_ids.fetchall())
-
-        if include_open:
-            db.q_open_election_ids.perform()
-            eids.extend(row.eid for row in db.q_open_election_ids.fetchall())
-
-        return eids
+        with closing(db.conn):
+            db.q_closed_election_ids.perform()
+            eids = [row.eid for row in db.q_closed_election_ids.fetchall()]
+            if include_open:
+                db.q_open_election_ids.perform()
+                eids.extend(row.eid for row in db.q_open_election_ids.fetchall())
+            return eids
 
     def set_open_at(self, timestamp):
         "Set the open_at timestamp for this Election."
diff --git a/v3/steve/persondb.py b/v3/steve/persondb.py
index e5b8de1..94b5694 100644
--- a/v3/steve/persondb.py
+++ b/v3/steve/persondb.py
@@ -26,19 +26,30 @@
 class PersonDB:
     @classmethod
     def open(cls, db_fname):
-        return cls(asfpy.db.DB(db_fname, yaml_fname=QUERIES, yaml_section='person'))
+        db = asfpy.db.DB(db_fname, yaml_fname=QUERIES, yaml_section='person')
+        db.conn.execute('PRAGMA foreign_keys = ON')
+        persondb = cls(db)
+        persondb._owns_db = True
+        return persondb
 
     def __init__(self, db):
         self.db = db
+        self._owns_db = False
 
     def __getattr__(self, name):
         "Proxy the cursors."
         return self.__dict__.get(name, getattr(self.db, name))
 
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_value, traceback):
+        if self._owns_db:
+            self.db.conn.close()
+
     def get_person(self, pid):
         "Return NAME, EMAIL for Person identified by PID."
 
-        # NEVER return person.salt
         person = self.q_get_person.first_row(pid)
         if not person:
             raise PersonNotFound(pid)
@@ -48,8 +59,6 @@ def get_person(self, pid):
     def add_person(self, pid, name, email):
         "Add or update a Person designated by PID."
 
-        # If we ADD, then SALT will be NULL. If we UPDATE, then it will not
-        # be touched (it should be NULL).
         self.c_add_person.perform(pid, name, email)
 
     def delete_person(self, pid):
diff --git a/v3/steve/vtypes/__init__.py b/v3/steve/vtypes/__init__.py
index b386b78..6652b9c 100644
--- a/v3/steve/vtypes/__init__.py
+++ b/v3/steve/vtypes/__init__.py
@@ -24,6 +24,7 @@
 
 def vtype_module(vtype):
     "Return the vote type's module."
-    assert vtype in TYPES
+    if vtype not in TYPES:
+        raise ValueError(f'Unsupported vote type: {vtype}')
 
     return globals()[vtype]
diff --git a/v3/steve/vtypes/stv.py b/v3/steve/vtypes/stv.py
index 1b9d0ca..33eba78 100644
--- a/v3/steve/vtypes/stv.py
+++ b/v3/steve/vtypes/stv.py
@@ -40,6 +40,8 @@ def load_stv():
 def get_candidates(kv):
     """Return normalized candidate details keyed by ballot label."""
 
+    if not isinstance(kv, dict):
+        raise ValueError('STV configuration must be a mapping')
     version = kv.get('version', 1)
     labelmap = kv.get('labelmap')
     if not isinstance(labelmap, dict):
@@ -70,6 +72,41 @@ def get_candidates(kv):
     return candidates
 
 
+def validate_configuration(kv) -> None:
+    """Reject configurations that cannot be represented unambiguously in a ballot."""
+    candidates = get_candidates(kv)
+    seats = kv.get('seats')
+    if type(seats) is not int or seats <= 0:
+        raise ValueError('STV seats must be a positive integer')
+    names = set()
+    for label, candidate in candidates.items():
+        if (
+            not isinstance(label, str)
+            or not label
+            or label.strip() != label
+            or ',' in label
+        ):
+            raise ValueError(
+                'STV labels must be nonempty, trimmed strings without commas'
+            )
+        name = candidate['name']
+        if not name.strip() or name in names:
+            raise ValueError('STV candidate names must be nonempty and unique')
+        names.add(name)
+
+
+def validate_vote(votestring: str, kv) -> None:
+    """Allow partial or empty rankings, but not unknown or repeated candidates."""
+    candidates = get_candidates(kv)
+    labels = [label.strip() for label in votestring.split(',')]
+    if not votestring.strip():
+        return
+    if any(label not in candidates for label in labels):
+        raise ValueError('Invalid STV candidate label')
+    if len(set(labels)) != len(labels):
+        raise ValueError('STV candidates may only be ranked once')
+
+
 def tally(votestrings, kv):
     """
     Run the STV tally process.
diff --git a/v3/steve/vtypes/yna.py b/v3/steve/vtypes/yna.py
index 086c472..5b8a3a6 100644
--- a/v3/steve/vtypes/yna.py
+++ b/v3/steve/vtypes/yna.py
@@ -31,6 +31,12 @@
 # "abstain" is any other (non-affirmative) value.
 
 
+def validate_vote(votestring: str, _kv) -> None:
+    """Reject unsupported submissions without changing how historical votes tally."""
+    if votestring not in YES | NO | {'a', 'abstain', ''}:
+        raise ValueError('Invalid YNA vote')
+
+
 def tally(votestrings, _kv):
     y = n = a = 0
     for v in votestrings:
diff --git a/v3/tests/README.md b/v3/tests/README.md
index 51cb6e5..6b9a459 100644
--- a/v3/tests/README.md
+++ b/v3/tests/README.md
@@ -2,6 +2,12 @@
 
 This directory contains scripts and utilities for testing the v3 codebase.
 
+## Regression Tests
+
+From `v3/`, run `uv run python -m unittest discover -s tests -p 'test_*.py'`. These tests use synthetic users, temporary SQLite databases, and Quart's HTTP test client; they do not require LDAP credentials or a running server.
+
+The suite exercises management authorization, CSRF, date storage, election and ballot atomicity, foreign-key enforcement, cross-election issue isolation, YAML imports, and a small mixed YNA/STV lifecycle. Run without `-O`: the current asfpy dependency explicitly requires assertions to be enabled. Historical STV equivalence still requires the fixtures described below.
+
 ## Code Coverage Testing
 
 To run code coverage testing, use the `check_coverage.py` script. This script uses the `coverage` library to measure code coverage of the `steve` package.
diff --git a/v3/tests/check_coverage.py b/v3/tests/check_coverage.py
index a3ba215..82c5106 100755
--- a/v3/tests/check_coverage.py
+++ b/v3/tests/check_coverage.py
@@ -49,11 +49,6 @@ def touch_every_line():
     conn.executescript(open(SCHEMA_FILE).read())
     conn.close()
 
-    # Ready to load up the Election and exercise it.
-    e = steve.election.Election.create(TESTING_DB, 'coverage', 'alice')
-
-    _ = e.get_metadata()  # while EDITABLE
-
     pdb = steve.persondb.PersonDB.open(TESTING_DB)
     pdb.add_person('alice', 'Alice', 'alice@example.org')
     pdb.add_person('bob', None, 'bob@example.org')
@@ -63,6 +58,9 @@ def touch_every_line():
     pdb.delete_person('david')
     _ = pdb.get_person('alice')
 
+    e = steve.election.Election.create(TESTING_DB, 'coverage', 'alice')
+    _ = e.get_metadata()  # while EDITABLE
+
     i1 = e.add_issue('issue A', None, 'yna', None)
     i2 = e.add_issue(
         'issue B',
diff --git a/v3/tests/support.py b/v3/tests/support.py
new file mode 100644
index 0000000..b79001d
--- /dev/null
+++ b/v3/tests/support.py
@@ -0,0 +1,57 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pathlib
+import sqlite3
+import tempfile
+import unittest
+
+from steve.election import Election
+from steve.persondb import PersonDB
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+class DatabaseTestCase(unittest.TestCase):
+    def setUp(self):
+        directory = tempfile.TemporaryDirectory(prefix='steve-test-')
+        self.addCleanup(directory.cleanup)
+        self.db_path = pathlib.Path(directory.name) / 'steve.db'
+        with sqlite3.connect(self.db_path) as conn:
+            conn.executescript((ROOT / 'schema.sql').read_text())
+        conn.close()
+        self.pdb = PersonDB.open(self.db_path)
+        self.addCleanup(self.pdb.db.conn.close)
+        for pid in ('alice', 'bob', 'carlos', 'iroh', 'wells'):
+            self.pdb.add_person(pid, pid.title(), f'{pid}@example.org')
+
+    def create_election(self, owner='alice'):
+        election = Election.create(self.db_path, 'Test election', owner)
+        self.addCleanup(election.close_database)
+        return election
+
+    def add_stv_issue(self, election):
+        return election.add_issue(
+            'Choose a candidate',
+            '',
+            'stv',
+            {
+                'version': 2,
+                'seats': 1,
+                'labelmap': {'a': ['alice', 'Alice'], 'b': ['bob', 'Bob']},
+            },
+        )
diff --git a/v3/tests/test_create_election.py b/v3/tests/test_create_election.py
new file mode 100644
index 0000000..a4ac580
--- /dev/null
+++ b/v3/tests/test_create_election.py
@@ -0,0 +1,89 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import importlib.util
+import sqlite3
+from unittest.mock import patch
+
+import yaml
+
+from support import DatabaseTestCase, ROOT
+from steve.election import Election
+
+spec = importlib.util.spec_from_file_location(
+    'create_election', ROOT / 'server/bin/create-election.py'
+)
+assert spec is not None and spec.loader is not None
+create_election = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(create_election)
+
+
+class CreateElectionTests(DatabaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.definition = {
+            'election': {'title': 'Election', 'owner_pid': 'alice'},
+            'issues': [{'title': 'Approve', 'vtype': 'yna'}],
+            'record': ['iroh'],
+        }
+
+    def run_import(self):
+        path = self.db_path.parent / 'election.yaml'
+        path.write_text(yaml.safe_dump(self.definition))
+        with patch.object(create_election, 'DB_FNAME', self.db_path):
+            return create_election.main(path)
+
+    def test_import_preserves_requested_voter_identity(self):
+        eid = self.run_import()
+        with Election(self.db_path, eid) as election:
+            self.assertEqual([v.pid for v in election.get_voters_for_email()], ['iroh'])
+
+    def test_unknown_or_duplicate_voters_leave_no_election(self):
+        for voters in (['missing'], ['bob', 'bob']):
+            with self.subTest(voters=voters):
+                self.definition['record'] = voters
+                with self.assertRaises(ValueError):
+                    self.run_import()
+                self.assertEqual(Election.owned_elections(self.db_path, 'alice'), [])
+
+    def test_storage_failure_rolls_back_election_and_issues(self):
+        self.pdb.db.conn.executescript("""
+            CREATE TRIGGER fail_issue BEFORE INSERT ON issue
+            WHEN NEW.title = 'broken'
+            BEGIN SELECT RAISE(ABORT, 'Storage failure'); END;
+        """)
+        self.definition['issues'].append({'title': 'broken', 'vtype': 'yna'})
+        with self.assertRaises(sqlite3.IntegrityError):
+            self.run_import()
+        self.assertEqual(Election.owned_elections(self.db_path, 'alice'), [])
+        self.assertEqual(
+            self.pdb.db.conn.execute('SELECT COUNT(*) AS n FROM issue').fetchone().n, 0
+        )
+
+    def test_yaml_dates_are_utc_and_epoch_zero_is_preserved(self):
+        self.assertEqual(create_election.parse_datetime(0), 0)
+        self.assertEqual(create_election.parse_datetime('1970-01-01'), 0)
+        self.assertEqual(create_election.parse_datetime(datetime.date(1970, 1, 1)), 0)
+        self.assertEqual(create_election.parse_datetime('1970-01-01T01:00:00+01:00'), 0)
+        self.definition['election']['open_at'] = datetime.date(2026, 11, 1)
+        eid = self.run_import()
+        with Election(self.db_path, eid) as election:
+            expected = int(
+                datetime.datetime(2026, 11, 1, tzinfo=datetime.timezone.utc).timestamp()
+            )
+            self.assertEqual(election.get_metadata().open_at, expected)
diff --git a/v3/tests/test_election.py b/v3/tests/test_election.py
new file mode 100644
index 0000000..b0a3bb3
--- /dev/null
+++ b/v3/tests/test_election.py
@@ -0,0 +1,250 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import sqlite3
+from unittest.mock import patch
+
+from support import DatabaseTestCase
+from steve.election import Election, ElectionBadState, ElectionNotFound, IssueNotFound
+from steve.persondb import PersonDB
+
+
+class ElectionTests(DatabaseTestCase):
+    def test_mixed_election_counts_only_latest_votes_after_closure(self):
+        election = self.create_election()
+        yna = election.add_issue('Approve', None, 'yna', None)
+        stv = self.add_stv_issue(election)
+        election.add_voter('alice')
+        election.add_voter('bob')
+        election.open(self.pdb)
+        election.add_votes('alice', {yna: 'n', stv: 'a,b'})
+        election.add_vote('alice', yna, 'y')
+        election.add_votes('bob', {yna: 'a', stv: 'a'})
+        with self.assertRaises(ElectionBadState):
+            election.tally_issue(yna)
+        election.close()
+        self.assertFalse(election.is_tampered(self.pdb))
+        result, voters = election.tally_issue(yna)
+        self.assertEqual(result[1], {'y': 1, 'n': 0, 'a': 1})
+        self.assertEqual(voters, {'alice', 'bob'})
+        result, _ = election.tally_issue(stv)
+        self.assertEqual(result[1]['candidates'], {'a': True, 'b': False})
+        with self.assertRaises(ElectionBadState):
+            election.add_vote('alice', yna, 'n')
+
+    def test_invalid_or_ineligible_issue_rejects_entire_ballot(self):
+        election = self.create_election()
+        yna = election.add_issue('Approve', '', 'yna', None)
+        stv = self.add_stv_issue(election)
+        election.add_voter('alice')
+        election.add_voter('bob', yna)
+        election.open(self.pdb)
+        for ranking in ('unknown', 'a,a', 'a,,b'):
+            with self.subTest(ranking=ranking), self.assertRaises(ValueError):
+                election.add_votes('alice', {yna: 'y', stv: ranking})
+        with self.assertRaises(PermissionError):
+            election.add_votes('bob', {yna: 'y', stv: 'a'})
+        self.assertFalse(any(election.has_voted_upon('alice').values()))
+        self.assertFalse(any(election.has_voted_upon('bob').values()))
+        election.add_votes('alice', {yna: 'y', stv: ''})
+        with self.assertRaises(ValueError):
+            election.add_votes('alice', {yna: 'n', stv: 'unknown'})
+        election.close()
+        self.assertEqual(election.tally_issue(yna)[0][1]['y'], 1)
+
+    def test_ambiguous_stv_configuration_does_not_create_an_issue(self):
+        election = self.create_election()
+        for kv in (
+            {'seats': 1, 'labelmap': {'a': 'Same name', 'b': 'Same name'}},
+            {'seats': 0, 'labelmap': {'a': 'Alice', 'b': 'Bob'}},
+        ):
+            with self.subTest(kv=kv), self.assertRaises(ValueError):
+                election.add_issue('Invalid election', '', 'stv', kv)
+        self.assertEqual(election.list_issues(), [])
+
+    def test_stv_can_elect_all_candidates_when_seats_exceed_candidates(self):
+        election = self.create_election()
+        iid = election.add_issue(
+            'Uncontested seats',
+            '',
+            'stv',
+            {'seats': 3, 'labelmap': {'a': 'Alice', 'b': 'Bob'}},
+        )
+        election.open(self.pdb)
+        election.close()
+        result, _ = election.tally_issue(iid)
+        self.assertEqual(result[1]['candidates'], {'a': True, 'b': True})
+
+    def test_storage_failure_rolls_back_earlier_votes(self):
+        election = self.create_election()
+        first = election.add_issue('First', '', 'yna', None)
+        second = election.add_issue('Second', '', 'yna', None)
+        election.add_voter('alice')
+        election.open(self.pdb)
+        election.db.conn.executescript("""
+            CREATE TRIGGER fail_second_vote BEFORE INSERT ON vote
+            WHEN (SELECT COUNT(*) FROM vote) > 0
+            BEGIN SELECT RAISE(ABORT, 'Storage failure'); END;
+        """)
+        with self.assertRaises(sqlite3.IntegrityError):
+            election.add_votes('alice', {first: 'y', second: 'n'})
+        self.assertFalse(any(election.has_voted_upon('alice').values()))
+        self.assertFalse(election.db.conn.in_transaction)
+
+    def test_failed_opening_rolls_back_salts(self):
+        election = self.create_election()
+        election.add_issue('Approve', '', 'yna', None)
+        election.add_voter('alice')
+        with patch('steve.crypto.gen_opened_key', side_effect=RuntimeError('Failure')):
+            with self.assertRaises(RuntimeError):
+                election.open(self.pdb)
+        self.assertTrue(election.is_editable())
+        rows = election.db.conn.execute('SELECT salt FROM mayvote').fetchall()
+        self.assertTrue(all(row['salt'] is None for row in rows))
+        election.open(self.pdb)
+        self.assertFalse(election.is_tampered(self.pdb))
+
+    def test_failed_ballot_rolls_back_inside_a_caller_transaction(self):
+        election = self.create_election()
+        first = election.add_issue('First', '', 'yna', None)
+        second = election.add_issue('Second', '', 'yna', None)
+        election.add_voter('alice')
+        election.open(self.pdb)
+        election.db.conn.executescript("""
+            CREATE TRIGGER fail_second_vote BEFORE INSERT ON vote
+            WHEN (SELECT COUNT(*) FROM vote) > 0
+            BEGIN SELECT RAISE(ABORT, 'Storage failure'); END;
+        """)
+        with election.db.conn:
+            election.db.conn.execute('BEGIN IMMEDIATE')
+            with self.assertRaises(sqlite3.IntegrityError):
+                election.add_votes('alice', {first: 'y', second: 'n'})
+            self.assertFalse(any(election.has_voted_upon('alice').values()))
+            self.assertTrue(election.db.conn.in_transaction)
+            election.add_vote('alice', first, 'n')
+        election.close()
+        self.assertEqual(election.tally_issue(first)[0][1]['n'], 1)
+        self.assertEqual(election.tally_issue(second)[1], set())
+
+    def test_concurrent_deletion_preserves_not_found_error_and_connection_ownership(
+        self,
+    ):
+        election = self.create_election()
+        with Election(self.db_path, election.eid) as other:
+            other.delete()
+        with self.assertRaises(ElectionNotFound):
+            election.close()
+        self.assertFalse(election.db.conn.in_transaction)
+        self.assertIsNotNone(election.db.conn.execute('SELECT 1').fetchone())
+
+    def test_delete_keeps_an_outer_transaction_open_until_the_caller_commits(self):
+        election = self.create_election()
+        with election.db.conn:
+            election.db.conn.execute('BEGIN IMMEDIATE')
+            election.delete()
+            self.assertTrue(election.db.conn.in_transaction)
+        self.assertEqual(Election.owned_elections(self.db_path, 'alice'), [])
+
+    def test_database_rollback_preserves_the_original_error(self):
+        election = self.create_election()
+        iid = election.add_issue('Approve', '', 'yna', None)
+        election.add_voter('alice')
+        election.open(self.pdb)
+        election.db.conn.executescript("""
+            CREATE TRIGGER fail_vote BEFORE INSERT ON vote
+            BEGIN SELECT RAISE(ROLLBACK, 'Storage rolled back transaction'); END;
+        """)
+        with election.db.conn:
+            election.db.conn.execute('BEGIN IMMEDIATE')
+            with self.assertRaisesRegex(sqlite3.IntegrityError, 'Storage rolled back'):
+                election.add_vote('alice', iid, 'y')
+        self.assertFalse(any(election.has_voted_upon('alice').values()))
+
+    def test_issue_operations_cannot_cross_election_boundaries(self):
+        first = self.create_election()
+        second = self.create_election('bob')
+        iid = second.add_issue('Private issue', '', 'yna', None)
+        operations = [
+            lambda: first.get_issue(iid),
+            lambda: first.edit_issue(iid, 'Changed', '', 'yna', None),
+            lambda: first.delete_issue(iid),
+            lambda: first.add_voter('alice', iid),
+        ]
+        for operation in operations:
+            with self.assertRaises(IssueNotFound):
+                operation()
+        self.assertEqual(second.get_issue(iid)[0], 'Private issue')
+        first.open(self.pdb)
+        first.close()
+        with self.assertRaises(IssueNotFound):
+            first.tally_issue(iid)
+
+    def test_non_collision_integrity_error_does_not_retry(self):
+        with patch('steve.crypto.create_id', side_effect=['0123456789']) as create_id:
+            with self.assertRaises(sqlite3.IntegrityError):
+                Election.create(self.db_path, 'Invalid owner', 'missing')
+        self.assertEqual(create_id.call_count, 1)
+
+    def test_collision_retries_without_overwriting_existing_election(self):
+        existing = self.create_election()
+        with patch('steve.crypto.create_id', side_effect=[existing.eid, '0123456789']):
+            with Election.create(self.db_path, 'New election', 'bob') as created:
+                self.assertEqual(created.get_metadata().owner_pid, 'bob')
+        self.assertEqual(existing.get_metadata().owner_pid, 'alice')
+
+    def test_issue_deletion_removes_eligibility_and_person_references_are_enforced(
+        self,
+    ):
+        election = self.create_election()
+        iid = election.add_issue('Approve', '', 'yna', None)
+        election.add_voter('bob')
+        with self.assertRaises(sqlite3.IntegrityError):
+            self.pdb.delete_person('bob')
+        election.delete_issue(iid)
+        self.pdb.delete_person('bob')
+        self.assertEqual(election.get_voters_for_email(), [])
+
+    def test_contexts_close_owned_connections_but_preserve_borrowed_connections(self):
+        with Election.create(self.db_path, 'Owned', 'alice') as owned:
+            connection = owned.db.conn
+        with self.assertRaises(sqlite3.ProgrammingError):
+            connection.execute('SELECT 1')
+        db = Election.open_database(self.db_path)
+        self.addCleanup(db.conn.close)
+        with db.conn:
+            db.conn.execute('BEGIN IMMEDIATE')
+            with Election.create(self.db_path, 'Borrowed', 'alice', db=db) as borrowed:
+                self.assertTrue(borrowed.is_editable())
+            with PersonDB(self.pdb.db) as borrowed_persondb:
+                self.assertEqual(borrowed_persondb.get_person('alice')[0], 'Alice')
+            self.assertTrue(db.conn.in_transaction)
+        self.assertIsNotNone(db.conn.execute('SELECT 1').fetchone())
+
+    def test_opened_election_rejects_edits_with_explicit_state_error(self):
+        election = self.create_election()
+        iid = election.add_issue('Approve', '', 'yna', None)
+        election.open(self.pdb)
+        for operation in (
+            lambda: election.add_issue('Other', '', 'yna', None),
+            lambda: election.edit_issue(iid, 'Changed', '', 'yna', None),
+            lambda: election.add_voter('bob'),
+            lambda: election.delete_issue(iid),
+            election.delete,
+            lambda: election.open(self.pdb),
+        ):
+            with self.assertRaises(ElectionBadState):
+                operation()
diff --git a/v3/tests/test_pages.py b/v3/tests/test_pages.py
new file mode 100644
index 0000000..7ae2952
--- /dev/null
+++ b/v3/tests/test_pages.py
@@ -0,0 +1,269 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import importlib.util
+import tempfile
+import time
+import unittest
+from html.parser import HTMLParser
+from unittest.mock import patch
+
+import asfquart
+
+from support import DatabaseTestCase, ROOT
+
+
+class Forms(HTMLParser):
+    def __init__(self, html):
+        super().__init__()
+        self.forms = {}
+        self.current = None
+        self.feed(html)
+
+    def handle_starttag(self, tag, attrs):
+        attrs = dict(attrs)
+        if tag == 'form':
+            self.current = {'method': attrs.get('method', 'GET'), 'fields': {}}
+            self.forms[attrs.get('action', '')] = self.current
+        elif tag == 'input' and self.current is not None and attrs.get('name'):
+            self.current['fields'][attrs['name']] = attrs.get('value', '')
+
+    def handle_endtag(self, tag):
+        if tag == 'form':
+            self.current = None
+
+
+class PageTests(DatabaseTestCase, unittest.IsolatedAsyncioTestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.app_directory = tempfile.TemporaryDirectory(prefix='steve-http-test-')
+        cls.addClassCleanup(cls.app_directory.cleanup)
+        cls.app = asfquart.construct(
+            'steve-tests', app_dir=cls.app_directory.name, token_file=None, oauth=False
+        )
+        cls.app.cfg.db = 'unused.db'
+        cls.app.config['TESTING'] = True
+        spec = importlib.util.spec_from_file_location(
+            'test_server_pages', ROOT / 'server/pages.py'
+        )
+        assert spec is not None and spec.loader is not None
+        cls.pages = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(cls.pages)
+
+    def setUp(self):
+        super().setUp()
+        db_patch = patch.object(self.pages, 'DB_FNAME', self.db_path)
+        db_patch.start()
+        self.addCleanup(db_patch.stop)
+
+    async def asyncSetUp(self):
+        self.client = self.app.test_client()
+        await self.login('alice')
+
+    async def login(self, pid):
+        async with self.client.session_transaction(scheme='https') as session:
+            session.clear()
+            session[self.app.app_id] = {
+                'uid': pid,
+                'fullname': pid.title(),
+                'pmcs': ['example'],
+                'uts': time.time(),
+            }
+        response = await self.client.get('/admin', scheme='https')
+        self.assertEqual(response.status_code, 200)
+        forms = Forms(await response.get_data(as_text=True))
+        self.csrf = forms.forms['/do-create-election']['fields']['csrf_token']
+
+    async def post(self, path, *, form=None, json=None):
+        if json is not None:
+            return await self.client.post(
+                path, json=json, headers={'X-CSRFToken': self.csrf}, scheme='https'
+            )
+        return await self.client.post(
+            path, form={'csrf_token': self.csrf, **(form or {})}, scheme='https'
+        )
+
+    async def test_create_form_and_owner_lifecycle_work_with_csrf(self):
+        response = await self.post(
+            '/do-create-election', form={'title': 'A real election'}
+        )
+        self.assertEqual(response.status_code, 303)
+        location = response.headers['Location']
+        eid = location.rsplit('/', 1)[-1]
+        response = await self.client.get(location, scheme='https')
+        self.assertEqual(response.status_code, 200)
+        forms = Forms(await response.get_data(as_text=True))
+        opening = forms.forms[f'/do-open/{eid}']
+        response = await self.client.open(
+            f'/do-open/{eid}',
+            method=opening['method'],
+            form=opening['fields'],
+            scheme='https',
+        )
+        self.assertEqual(response.status_code, 303)
+        response = await self.client.get(location, scheme='https')
+        closing = Forms(await response.get_data(as_text=True)).forms[f'/do-close/{eid}']
+        response = await self.client.open(
+            f'/do-close/{eid}',
+            method=closing['method'],
+            form=closing['fields'],
+            scheme='https',
+        )
+        self.assertEqual(response.status_code, 303)
+        response = await self.post(f'/do-close/{eid}')
+        self.assertEqual(response.status_code, 409)
+
+    async def test_csrf_rejects_missing_wrong_and_other_session_tokens(self):
+        for token in (None, 'wrong', '\N{SNOWMAN} token'):
+            form = {'title': 'Rejected'}
+            if token is not None:
+                form['csrf_token'] = token
+            response = await self.client.post(
+                '/do-create-election', form=form, scheme='https'
+            )
+            self.assertEqual(response.status_code, 400)
+        previous = self.csrf
+        await self.login('bob')
+        response = await self.client.post(
+            '/do-create-election',
+            form={'title': 'Rejected', 'csrf_token': previous},
+            scheme='https',
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            self.pages.steve.election.Election.owned_elections(self.db_path, 'alice'),
+            [],
+        )
+
+    async def test_unrelated_committer_cannot_manage_election(self):
+        election = self.create_election()
+        iid = election.add_issue('Approve', '', 'yna', None)
+        stv = self.add_stv_issue(election)
+        await self.login('bob')
+        for path in (f'/manage/{election.eid}', f'/manage-stv/{election.eid}/{stv}'):
+            response = await self.client.get(path, scheme='https')
+            self.assertEqual(response.status_code, 403)
+        for path in (
+            f'/do-open/{election.eid}',
+            f'/do-close/{election.eid}',
+            f'/do-add-issue/{election.eid}',
+            f'/do-edit-issue/{election.eid}/{iid}',
+            f'/do-delete-issue/{election.eid}/{iid}',
+        ):
+            response = await self.post(
+                path, form={'title': 'Changed', 'description': ''}
+            )
+            self.assertEqual(response.status_code, 403)
+        response = await self.post(
+            f'/do-set-open_at/{election.eid}', json={'date': '2026-11-01'}
+        )
+        self.assertEqual(response.status_code, 403)
+        self.assertTrue(election.is_editable())
+        self.assertEqual(election.get_issue(iid)[0], 'Approve')
+
+    async def test_open_and_close_get_requests_never_change_state(self):
+        election = self.create_election()
+        for action in ('open', 'close'):
+            response = await self.client.get(
+                f'/do-{action}/{election.eid}', scheme='https'
+            )
+            self.assertEqual(response.status_code, 405)
+        self.assertTrue(election.is_editable())
+
+    async def test_issue_from_another_election_is_not_editable_through_owned_election(
+        self,
+    ):
+        owned = self.create_election()
+        other = self.create_election('bob')
+        iid = other.add_issue('Other issue', '', 'yna', None)
+        response = await self.post(
+            f'/do-edit-issue/{owned.eid}/{iid}', form={'title': 'Changed'}
+        )
+        self.assertEqual(response.status_code, 404)
+        self.assertEqual(other.get_issue(iid)[0], 'Other issue')
+
+    async def test_date_validation_and_utc_roundtrip(self):
+        election = self.create_election()
+        path = f'/do-set-open_at/{election.eid}'
+        for data in ([], {'date': 123}, {'date': 'not-a-date'}, {'date': ''}):
+            response = await self.post(path, json=data)
+            self.assertEqual(response.status_code, 400)
+        response = await self.post(path, json={'date': '1970-01-01'})
+        self.assertEqual(response.status_code, 204)
+        response = await self.client.get(f'/manage/{election.eid}', scheme='https')
+        self.assertIn('value="1970-01-01"', await response.get_data(as_text=True))
+        response = await self.post(path, json={'date': '2026-11-01'})
+        self.assertEqual(response.status_code, 204)
+        expected = int(
+            datetime.datetime(2026, 11, 1, tzinfo=datetime.timezone.utc).timestamp()
+        )
+        self.assertEqual(election.get_metadata().open_at, expected)
+        response = await self.client.get(f'/manage/{election.eid}', scheme='https')
+        html = await response.get_data(as_text=True)
+        self.assertIn('value="2026-11-01"', html)
+        election.open(self.pdb)
+        response = await self.post(path, json={'date': '2026-12-01'})
+        self.assertEqual(response.status_code, 409)
+        response = await self.post(
+            f'/do-set-close_at/{election.eid}', json={'date': '2026-12-01'}
+        )
+        self.assertEqual(response.status_code, 204)
+        election.close()
+        response = await self.post(
+            f'/do-set-close_at/{election.eid}', json={'date': '2026-12-02'}
+        )
+        self.assertEqual(response.status_code, 409)
+
+    async def test_voter_only_sees_eligible_issues_and_bad_ballot_is_atomic(self):
+        election = self.create_election()
+        public = election.add_issue('Eligible issue', None, 'yna', None)
+        private = election.add_issue('Private issue', '', 'yna', None)
+        election.add_voter('bob', public)
+        election.open(self.pdb)
+        await self.login('bob')
+        response = await self.client.get(f'/vote-on/{election.eid}', scheme='https')
+        self.assertEqual(response.status_code, 200)
+        html = await response.get_data(as_text=True)
+        self.assertIn('Eligible issue', html)
+        self.assertNotIn('Private issue', html)
+        response = await self.post(
+            f'/do-vote/{election.eid}',
+            form={f'vote-{public}': 'y', f'vote-{private}': 'n'},
+        )
+        self.assertEqual(response.status_code, 403)
+        self.assertFalse(any(election.has_voted_upon('bob').values()))
+        response = await self.post(
+            f'/do-vote/{election.eid}', form={f'vote-{public}': 'y'}
+        )
+        self.assertEqual(response.status_code, 303)
+        self.assertTrue(election.has_voted_upon('bob')[public])
+
+    async def test_invalid_stv_submission_is_rejected_before_any_issue_is_recorded(
+        self,
+    ):
+        election = self.create_election()
+        yna = election.add_issue('Approve', '', 'yna', None)
+        stv = self.add_stv_issue(election)
+        election.add_voter('alice')
+        election.open(self.pdb)
+        response = await self.post(
+            f'/do-vote/{election.eid}',
+            form={f'vote-{yna}': 'y', f'vote-{stv}': 'unknown'},
+        )
+        self.assertEqual(response.status_code, 400)
+        self.assertFalse(any(election.has_voted_upon('alice').values()))
diff --git a/v3/tests/test_tally.py b/v3/tests/test_tally.py
new file mode 100644
index 0000000..3a6acfd
--- /dev/null
+++ b/v3/tests/test_tally.py
@@ -0,0 +1,55 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+import subprocess
+import sys
+
+from support import DatabaseTestCase, ROOT
+
+
+class TallyTests(DatabaseTestCase):
+    def test_tally_cli_outputs_json_and_refuses_tampered_election(self):
+        election = self.create_election()
+        iid = election.add_issue('Approve', '', 'yna', None)
+        election.add_voter('alice')
+        election.open(self.pdb)
+        election.add_vote('alice', iid, 'y')
+        election.close()
+        command = [
+            sys.executable,
+            str(ROOT / 'server/bin/tally.py'),
+            '--db-path',
+            str(self.db_path),
+            '--issue-id',
+            iid,
+            '--output',
+            'json',
+        ]
+        result = subprocess.run(command, capture_output=True, text=True, check=True)
+        data = json.loads(result.stdout)
+        self.assertEqual(
+            data['results'][iid]['supporting_data'], {'y': 1, 'n': 0, 'a': 0}
+        )
+        self.assertEqual(data['voters'], ['alice'])
+        election.db.conn.execute(
+            'UPDATE issue SET title = ? WHERE iid = ?', ('Changed', iid)
+        )
+        result = subprocess.run(command, capture_output=True, text=True)
+        self.assertNotEqual(result.returncode, 0)
+        self.assertEqual(result.stdout, '')
+        self.assertTrue(result.stderr)