You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/04/14 09:11:28 UTC

(tooling-trusted-releases) branch database_error_handling created (now 2afec15e)

This is an automated email from the ASF dual-hosted git repository.

arm pushed a change to branch database_error_handling
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


      at 2afec15e #1158 - move error handling to database

This branch includes the following new commits:

     new 2afec15e #1158 - move error handling to database

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org


(tooling-trusted-releases) 01/01: #1158 - move error handling to database

Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

arm pushed a commit to branch database_error_handling
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 2afec15e674d697fa21234298ddb91ff484db02b
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Tue Apr 14 10:11:09 2026 +0100

    #1158 - move error handling to database
---
 atr/admin/__init__.py                           | 30 +++++++++----------
 atr/blueprints/common.py                        |  4 +--
 atr/form.py                                     | 22 +++++---------
 atr/get/announce.py                             |  2 +-
 atr/get/checks.py                               |  2 +-
 atr/get/committees.py                           |  2 +-
 atr/get/compose.py                              | 10 +++----
 atr/get/distribution.py                         |  6 ++--
 atr/get/draft.py                                |  6 ++--
 atr/get/finish.py                               | 12 ++++----
 atr/get/ignores.py                              | 21 +++++++------
 atr/get/keys.py                                 | 22 +++++++-------
 atr/get/manual.py                               |  8 ++---
 atr/get/projects.py                             | 34 ++++++++++-----------
 atr/get/resolve.py                              |  2 +-
 atr/get/revisions.py                            | 10 +++----
 atr/get/sbom.py                                 | 26 ++++++++--------
 atr/get/start.py                                |  2 +-
 atr/get/test.py                                 |  8 ++---
 atr/get/tokens.py                               | 10 +++----
 atr/get/upload.py                               |  4 +--
 atr/get/user.py                                 |  8 ++---
 atr/get/vote.py                                 |  2 +-
 atr/get/voting.py                               |  2 +-
 atr/models/sql.py                               | 10 +++++++
 atr/post/user.py                                |  2 +-
 atr/sessions.py                                 | 40 +++++++++++++++++++++++++
 atr/shared/keys.py                              |  4 +--
 atr/tasks/maintenance.py                        | 25 ++++++++++++++++
 atr/templates/macros/flash.html                 | 10 +++----
 atr/web.py                                      |  4 +--
 migrations/versions/0066_2026.04.13_a7d14b22.py | 32 ++++++++++++++++++++
 tests/unit/test_vote_resolution.py              |  8 ++++-
 33 files changed, 248 insertions(+), 142 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 8a931ad7..9064e30c 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -155,7 +155,7 @@ async def browse_as_get(_session: web.Committer, _browse_as: Literal["browse-as"
 
     Allows an admin to browse as another user.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=BrowseAsUserForm,
         submit_label="Browse as this user",
     )
@@ -323,7 +323,7 @@ async def delete_committee_keys_get(
 
     committee_choices = [(c.key, c.display_name) for c in committees_with_keys]
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=DeleteCommitteeKeysForm,
         submit_label="Delete all keys for selected committee",
         defaults={"committee_key": committee_choices},
@@ -402,7 +402,7 @@ async def delete_release_get(
     else:
         releases_widget_with_help = htpy.p(".text-muted")["No releases found in the database."]
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=DeleteReleaseForm,
         submit_label="Delete selected releases permanently",
         submit_classes="btn-danger",
@@ -438,7 +438,7 @@ async def delete_test_openpgp_keys_get(
     if not config.is_test_mode():
         return quart.abort(404)
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Delete all OpenPGP keys for test user",
         empty=True,
@@ -484,7 +484,7 @@ async def keys_check_get(_session: web.Committer, _keys_check: Literal["keys/che
 
     Check public signing key details.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Check public signing key details",
         empty=True,
@@ -513,7 +513,7 @@ async def keys_regenerate_all_get(
 
     Display the form to regenerate KEYS files.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Regenerate all KEYS files",
         empty=True,
@@ -555,7 +555,7 @@ async def keys_update_get(
 
     Update keys from remote data.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Update keys",
         empty=True,
@@ -596,7 +596,7 @@ async def ldap_get(session: web.Committer, _ldap: Literal["ldap"]) -> str:
     """
     URL: GET /ldap
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=LdapLookupForm,
         submit_label="Lookup",
     )
@@ -635,7 +635,7 @@ async def ldap_post(session: web.Committer, _ldap: Literal["ldap"], lookup_form:
         end = time.perf_counter_ns()
         log.info(f"LDAP search took {(end - start) / 1000000} ms")
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=LdapLookupForm,
         submit_label="Lookup",
         defaults={"uid": uid_query, "email": email_query},
@@ -779,7 +779,7 @@ async def projects_update_get(
 
     Update projects from remote data.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Update projects",
         empty=True,
@@ -834,7 +834,7 @@ async def revoke_user_tokens_get(_session: web.Committer, _revoke_user_tokens: L
         rows = await data.execute_query(stmt)
         token_counts = [(row[0], row[1]) for row in rows]
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=RevokeUserTokensForm,
         submit_label="Revoke all tokens",
     )
@@ -875,7 +875,7 @@ async def rotate_jwt_key_get(_session: web.Committer, _rotate_jwt_key: Literal["
     """
     URL: GET /rotate-jwt-key
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=RotateJwtKeyForm,
         submit_label="Rotate JWT key",
     )
@@ -1047,7 +1047,7 @@ async def toggle_view_get(_session: web.Committer, _toggle_view: Literal["toggle
 
     Display the page with a button to toggle between admin and user views.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Toggle view",
         empty=True,
@@ -1100,7 +1100,7 @@ async def validate_jwt_get(_session: web.Committer, _validate_jwt: Literal["vali
     URL: GET /validate-jwt
     """
     _require_non_production_mode()
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=ValidateJwtForm,
         submit_label="Validate JWT",
         textarea_rows=8,
@@ -1138,7 +1138,7 @@ async def validate_jwt_post(
             "message": str(exc),
         }
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=ValidateJwtForm,
         submit_label="Validate JWT",
         textarea_rows=8,
diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
index 28db268c..5b514a14 100644
--- a/atr/blueprints/common.py
+++ b/atr/blueprints/common.py
@@ -241,8 +241,6 @@ async def validate_safe_fields(
 
 async def flash_form_error(form_cls: type, error: pydantic.ValidationError) -> Any:
     """Flash form validation errors and return a redirect to the current page."""
-    import json
-
     errors = error.errors()
     if len(errors) == 0:
         raise RuntimeError("Validation failed, but no errors were reported")
@@ -250,7 +248,7 @@ async def flash_form_error(form_cls: type, error: pydantic.ValidationError) -> A
     flash_data = form.flash_error_data(form_cls, errors, form_data_raw)
     summary = form.flash_error_summary(errors, flash_data)
     await quart.flash(summary, category="error")
-    await quart.flash(json.dumps(flash_data), category="form-error-data")
+    await sessions.form_error_put(quart.request.path, flash_data)
     return quart.redirect(quart.request.path)
 
 
diff --git a/atr/form.py b/atr/form.py
index 29df179f..18c31277 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -18,7 +18,6 @@
 from __future__ import annotations
 
 import enum
-import json
 import pathlib
 import re
 import types
@@ -35,6 +34,7 @@ import quart_wtf.utils as utils
 import atr.htm as htm
 import atr.models.safe as safe
 import atr.models.schema as schema
+import atr.sessions as sessions
 import atr.util as util
 
 if TYPE_CHECKING:
@@ -249,19 +249,11 @@ def _get_concrete_cls(form_cls: TypeAliasType, discriminator_value: str) -> type
     raise ValueError(f"Discriminator value {discriminator_value} not found in union type: {alias_value}")
 
 
-def _get_flash_error_data() -> dict[str, Any]:
-    flashed_error_messages = quart.get_flashed_messages(category_filter=["form-error-data"])
-    if flashed_error_messages:
-        try:
-            first_message = flashed_error_messages[0]
-            if isinstance(first_message, str):
-                return json.loads(first_message)
-        except (json.JSONDecodeError, IndexError):
-            pass
-    return {}
+async def _get_flash_error_data() -> dict[str, Any]:
+    return await sessions.form_error_pop(quart.request.path)
 
 
-def render(  # noqa: C901
+async def render(  # noqa: C901
     model_cls: type[Form],
     action: str | None = None,
     form_classes: str = ".atr-canary.py-4",
@@ -292,7 +284,7 @@ def render(  # noqa: C901
     elif border and (".px-" not in form_classes):
         form_classes += ".px-5"
 
-    flash_error_data: dict[str, Any] = _get_flash_error_data() if use_error_data else {}
+    flash_error_data: dict[str, Any] = await _get_flash_error_data() if use_error_data else {}
     field_rows: list[htm.Element] = []
     hidden_fields: list[htm.Element | htm.VoidElement | markupsafe.Markup] = []
     hidden_fields.append(csrf_input())
@@ -359,8 +351,8 @@ def render(  # noqa: C901
     return htm.form(form_classes, **form_attrs)[form_children]
 
 
-def render_block(block: htm.Block, *args, **kwargs) -> None:
-    rendered = render(*args, **kwargs)
+async def render_block(block: htm.Block, *args, **kwargs) -> None:
+    rendered = await render(*args, **kwargs)
     block.append(rendered)
 
 
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 32e765c9..e3614862 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -237,7 +237,7 @@ async def _render_page(
             "body": default_body,
         }
 
-        form.render_block(
+        await form.render_block(
             page,
             model_cls=shared.announce.AnnounceForm,
             action=util.as_url(post.announce.selected, project_key=release.project.key, version_key=release.version),
diff --git a/atr/get/checks.py b/atr/get/checks.py
index a0f870f6..1f075d5b 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -177,7 +177,7 @@ async def selected_revision(
     if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
         for path in all_paths:
             delete_file_forms[str(path)] = str(
-                form.render(
+                await form.render(
                     model_cls=draft.DeleteFileForm,
                     action=util.as_url(
                         post.draft.delete_file, project_key=str(project_key), version_key=str(version_key)
diff --git a/atr/get/committees.py b/atr/get/committees.py
index e5a663f9..d1072a34 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -75,7 +75,7 @@ async def view(session: web.Public, _committees: Literal["committees"], name: sa
         now=datetime.datetime.now(datetime.UTC),
         email_from_key=util.email_from_uid,
         is_committee_member=committee_member,
-        update_committee_keys_form=form.render(
+        update_committee_keys_form=await form.render(
             model_cls=shared.keys.UpdateCommitteeKeysForm,
             action=util.as_url(post.keys.keys),
             submit_label="Regenerate KEYS file",
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 65b740ec..79d5c111 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -96,7 +96,7 @@ async def selected(
     for q in quarantined_failed:
         if q.id is None:
             continue
-        clear_quarantine_forms[q.id] = form.render(
+        clear_quarantine_forms[q.id] = await form.render(
             model_cls=draft.ClearQuarantineForm,
             action=util.as_url(
                 post.draft.quarantine_clear,
@@ -124,7 +124,7 @@ async def selected(
             revision_editor = None
             revision_timestamp = None
 
-    delete_form = form.render(
+    delete_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(selected, project_key=release.safe_project_key, version_key=release.safe_version_key),
         submit_label="Delete this draft",
@@ -135,7 +135,7 @@ async def selected(
 
     delete_file_forms: dict[str, htm.Element] = {}
     for path in all_paths:
-        delete_file_forms[str(path)] = form.render(
+        delete_file_forms[str(path)] = await form.render(
             model_cls=draft.DeleteFileForm,
             action=util.as_url(
                 post.draft.delete_file, project_key=release.safe_project_key, version_key=release.safe_version_key
@@ -153,7 +153,7 @@ async def selected(
             ),
         )
 
-    recheck_form = form.render(
+    recheck_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             post.draft.recheck, project_key=release.safe_project_key, version_key=release.safe_version_key
@@ -161,7 +161,7 @@ async def selected(
         submit_label="Recheck with fresh cache",
         submit_classes="btn btn-primary",
     )
-    cache_reset_form = form.render(
+    cache_reset_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             post.draft.cache_reset, project_key=release.safe_project_key, version_key=release.safe_version_key
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 0e45faa0..a5cb4b55 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -119,7 +119,7 @@ async def list_get(
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
 
-        delete_form = form.render(
+        delete_form = await form.render(
             model_cls=shared.distribution.DeleteForm,
             action=util.as_url(post.distribution.delete, project_key=str(project_key), version_key=str(version_key)),
             form_classes=".d-inline-block.m-0",
@@ -214,7 +214,7 @@ async def _automate_form_page(project: safe.ProjectKey, version: safe.VersionKey
     )
 
     # Render the distribution form
-    form_html = form.render(
+    form_html = await form.render(
         model_cls=shared.distribution.DistributionAutomateForm,
         submit_label="Distribute",
         action=action,
@@ -290,7 +290,7 @@ async def _record_form_page(project: safe.ProjectKey, version: safe.VersionKey,
     )
 
     # Render the distribution form
-    form_html = form.render(
+    form_html = await form.render(
         model_cls=shared.distribution.DistributionRecordForm,
         submit_label="Record distribution",
         action=action,
diff --git a/atr/get/draft.py b/atr/get/draft.py
index 12d6c70c..3172b445 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -71,14 +71,14 @@ async def tools(
         version_key=str(version_key),
         file_path=str(file_path),
     )
-    sha512_form = form.render(
+    sha512_form = await form.render(
         model_cls=shared.draft.HashGen,
         action=hashgen_action,
         submit_label="Generate SHA512",
         submit_classes="btn-outline-secondary",
         empty=True,
     )
-    sbom_form = form.render(
+    sbom_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             post.draft.sbomgen,
@@ -90,7 +90,7 @@ async def tools(
         submit_classes="btn-outline-secondary",
         empty=True,
     )
-    sbom_convert_form = form.render(
+    sbom_convert_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             post.draft.sbomconvert,
diff --git a/atr/get/finish.py b/atr/get/finish.py
index e07abd4b..c90ee0fd 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -189,13 +189,13 @@ async def _get_page_data(
     return release, deletable_dirs, rc_analysis_result, tasks
 
 
-def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> htm.Element:
+async def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> htm.Element:
     """Render the delete directory form."""
     section = htm.Block()
 
     section.h2["Delete an empty directory"]
 
-    form.render_block(
+    await form.render_block(
         section,
         shared.finish.DeleteEmptyDirectoryForm,
         defaults={"directory_to_delete": deletable_dirs},
@@ -329,10 +329,10 @@ async def _render_page(
 
     # Delete directory form
     if deletable_dirs:
-        page.append(_render_delete_directory_form(deletable_dirs))
+        page.append(await _render_delete_directory_form(deletable_dirs))
 
     # Remove RC tags section
-    page.append(_render_rc_tags_section(rc_analysis))
+    page.append(await _render_rc_tags_section(rc_analysis))
 
     # Custom styles
     page_styles = """
@@ -380,7 +380,7 @@ def _render_rc_preview_table(affected_paths: list[tuple[str, str]]) -> htm.Eleme
     ]
 
 
-def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element:
+async def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element:
     """Render the remove RC tags section."""
     section = htm.Block()
 
@@ -394,7 +394,7 @@ def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element:
             _render_rc_preview_table(rc_analysis.affected_paths_preview) if rc_analysis.affected_paths_preview else "",
         ]
 
-        form.render_block(
+        await form.render_block(
             section,
             shared.finish.RemoveRCTagsForm,
             submit_label="Remove RC tags",
diff --git a/atr/get/ignores.py b/atr/get/ignores.py
index 290b9f7a..9d986886 100644
--- a/atr/get/ignores.py
+++ b/atr/get/ignores.py
@@ -44,22 +44,24 @@ async def ignores(
         ragp = read.as_general_public()
         ignores = await ragp.checks.ignores(project_key)
 
+    add_ignore_element = await _add_ignore(str(project_key))
+    existing_ignores_element = await _existing_ignores(ignores)
     content = htm.div[
         htm.h1["Ignored checks"],
         htm.p[f"Manage ignored checks for project {project_key!s}."],
-        _add_ignore(str(project_key)),
-        _existing_ignores(ignores),
+        add_ignore_element,
+        existing_ignores_element,
     ]
 
     return await template.blank("Ignored checks", content, javascripts=["ignore-form-change"])
 
 
-def _add_ignore(project_key: str) -> htm.Element:
+async def _add_ignore(project_key: str) -> htm.Element:
     form_path = util.as_url(post.ignores.ignores, project_key=project_key)
     block = htm.Block(htm.div)
     block.h2["Add ignore"]
     block.p["Add a new ignore for a check result."]
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.ignores.AddIgnoreForm,
         action=form_path,
@@ -68,7 +70,7 @@ def _add_ignore(project_key: str) -> htm.Element:
     return block.collect()
 
 
-def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element:
+async def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element:
     h3_id = cri.id or ""
     h3_asf_uid = cri.asf_uid
     h3_created = util.format_datetime(cri.created)
@@ -78,7 +80,7 @@ def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element:
     update_form_block = htm.Block(htm.div)
     form_path_update = util.as_url(post.ignores.ignores, project_key=cri.project_key)
     status = shared.ignores.sql_to_ignore_status(cri.status)
-    form.render_block(
+    await form.render_block(
         update_form_block,
         model_cls=shared.ignores.UpdateIgnoreForm,
         action=form_path_update,
@@ -98,7 +100,7 @@ def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element:
 
     # Delete form
     delete_form_block = htm.Block(htm.div)
-    form.render_block(
+    await form.render_block(
         delete_form_block,
         model_cls=shared.ignores.DeleteIgnoreForm,
         action=form_path_update,
@@ -117,8 +119,9 @@ def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element:
     return card
 
 
-def _existing_ignores(ignores: list[sql.CheckResultIgnore]) -> htm.Element:
+async def _existing_ignores(ignores: list[sql.CheckResultIgnore]) -> htm.Element:
+    cards = [await _check_result_ignore_card(cri) for cri in ignores]
     return htm.div[
         htm.h2["Existing ignores"],
-        [_check_result_ignore_card(cri) for cri in ignores] or htm.p["No ignores found."],
+        cards or htm.p["No ignores found."],
     ]
diff --git a/atr/get/keys.py b/atr/get/keys.py
index de1977ac..0b8506e8 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -54,7 +54,7 @@ async def add(_session: web.Committer, _keys_add: Literal["keys/add"]) -> str:
         htm.h1(".mb-4")["Add your OpenPGP key"],
         htm.p["Add your public key to use for signing release artifacts."],
     ]
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.AddOpenPGPKeyForm,
         action=util.as_url(post.keys.add),
@@ -226,9 +226,9 @@ async def keys(session: web.Committer, _keys: Literal["keys"]) -> str:
         htm.a(".btn.btn-outline-primary", href=util.as_url(ssh_add))["Add your SSH key"],
     ]
 
-    _openpgp_keys(page, list(user_keys))
-    _ssh_keys(page, list(user_ssh_keys))
-    _committee_keys(page, list(user_committees_with_keys))
+    await _openpgp_keys(page, list(user_keys))
+    await _ssh_keys(page, list(user_ssh_keys))
+    await _committee_keys(page, list(user_committees_with_keys))
 
     return await template.blank(
         "Manage keys",
@@ -255,7 +255,7 @@ async def ssh_add(session: web.Committer, _keys_ssh_add: Literal["keys/ssh/add"]
         ]
     ]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.AddSSHKeyForm,
         action=util.as_url(post.keys.ssh_add),
@@ -278,7 +278,7 @@ async def upload(_session: web.Committer, _keys_upload: Literal["keys/upload"])
     return await shared.keys.render_upload_page()
 
 
-def _committee_keys(page: htm.Block, user_committees_with_keys: list[sql.Committee]) -> None:
+async def _committee_keys(page: htm.Block, user_committees_with_keys: list[sql.Committee]) -> None:
     page.h2("#your-committee-keys")["Your committee's keys"]
     page.div(".mb-4")[htm.a(".btn.btn-outline-primary", href=util.as_url(upload))["Upload a KEYS file"]]
 
@@ -314,7 +314,7 @@ def _committee_keys(page: htm.Block, user_committees_with_keys: list[sql.Committ
                     " but you can also use the form below to manually regenerate it.",
                 ]
 
-                form.render_block(
+                await form.render_block(
                     page,
                     model_cls=shared.keys.UpdateCommitteeKeysForm,
                     action=util.as_url(post.keys.keys),
@@ -357,7 +357,7 @@ async def _key_and_is_owner(
     return key, is_owner
 
 
-def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> None:
+async def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> None:
     page.h3["Your OpenPGP keys"]
     if user_keys:
         thead = htm.thead[
@@ -380,7 +380,7 @@ def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> Non
             else:
                 row.td(".text-break.px-2.align-middle")["No PMCs associated"]
             with row.block(htm.td, classes=".px-2") as td:
-                form.render_block(
+                await form.render_block(
                     td,
                     model_cls=shared.keys.DeleteOpenPGPKeyForm,
                     action=util.as_url(post.keys.keys),
@@ -423,7 +423,7 @@ def _render_committee_checkboxes(
     return row_div.collect()
 
 
-def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
+async def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
     page.h3["Your SSH keys"]
     if user_ssh_keys:
         grid = htm.Block(htm.div, classes=".d-grid.gap-4")
@@ -447,7 +447,7 @@ def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
                 htm.pre(".mt-3")[key.key],
             ]
 
-            form.render_block(
+            await form.render_block(
                 card_block,
                 model_cls=shared.keys.DeleteSSHKeyForm,
                 action=util.as_url(post.keys.keys),
diff --git a/atr/get/manual.py b/atr/get/manual.py
index e3a891b8..e12b4275 100644
--- a/atr/get/manual.py
+++ b/atr/get/manual.py
@@ -56,7 +56,7 @@ async def resolve_selected(
     if not release.vote_manual:
         raise RuntimeError("This page is for manual votes only")
 
-    content = _render_resolve_page(release)
+    content = await _render_resolve_page(release)
 
     return await template.blank(
         title="Resolve vote",
@@ -135,7 +135,7 @@ async def _render_page(release, revision: str) -> htm.Element:
     ]
 
     cancel_url = util.as_url(compose.selected, project_key=release.project.key, version_key=release.version)
-    manual_form = form.render(
+    manual_form = await form.render(
         model_cls=form.Empty,
         submit_label="Start manual vote",
         cancel_url=cancel_url,
@@ -152,7 +152,7 @@ async def _render_page(release, revision: str) -> htm.Element:
     return page.collect()
 
 
-def _render_resolve_page(release: sql.Release) -> htm.Element:
+async def _render_resolve_page(release: sql.Release) -> htm.Element:
     page = htm.Block()
 
     back_url = util.as_url(vote.selected, project_key=release.project.key, version_key=release.version)
@@ -165,7 +165,7 @@ def _render_resolve_page(release: sql.Release) -> htm.Element:
         "For a cancellation, provide the URL of the thread where you sent the cancellation notice."
     ]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.manual.ResolveVoteForm,
         form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 094d9636..e940a578 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -65,7 +65,7 @@ async def add_project(
 
     committee_display_name = committee.name or committee.key.title()
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.projects.AddProjectForm,
         action=util.as_url(post.projects.add_project, committee_key=committee.key),
@@ -104,7 +104,7 @@ async def projects(_session: web.Public, _projects: Literal["projects"]) -> str:
 
     delete_forms: dict[str, htm.Element] = {}
     for project in projects:
-        delete_forms[str(project.key)] = form.render(
+        delete_forms[str(project.key)] = await form.render(
             model_cls=shared.projects.DeleteSelectedProject,
             action=util.as_url(post.projects.delete),
             form_classes=".d-inline-block.m-0",
@@ -199,10 +199,10 @@ async def view(
 
     if project.status == sql.ProjectStatus.ACTIVE:
         if can_edit:
-            page.append(_render_compose_form(project))
-            page.append(_render_vote_form(project))
-            page.append(_render_finish_form(project))
-            page.append(_render_trusted_publishing_form(project))
+            page.append(await _render_compose_form(project))
+            page.append(await _render_vote_form(project))
+            page.append(await _render_finish_form(project))
+            page.append(await _render_trusted_publishing_form(project))
         else:
             page.append(_render_policy_readonly(project))
 
@@ -214,7 +214,7 @@ async def view(
         page.append(await _render_releases_sections(project, candidate_drafts, candidates, previews, full_releases))
 
         if project.created_by == session.uid:
-            page.append(_render_delete_section(project))
+            page.append(await _render_delete_section(project))
 
         if project.committee:
             if (project.committee.key in session.committees) or is_privileged:
@@ -335,7 +335,7 @@ def _render_categories_section(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
-def _render_compose_form(project: sql.Project) -> htm.Element:
+async def _render_compose_form(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Compose options"]
@@ -347,7 +347,7 @@ def _render_compose_form(project: sql.Project) -> htm.Element:
     else:
         atr_tag_yaml = ""
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.ComposePolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
@@ -367,11 +367,11 @@ def _render_compose_form(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
-def _render_delete_section(project: sql.Project) -> htm.Element:
+async def _render_delete_section(project: sql.Project) -> htm.Element:
     section = htm.Block(htm.div)
     section.h2["Actions"]
 
-    delete_form = form.render(
+    delete_form = await form.render(
         shared.projects.DeleteProjectForm,
         action=util.as_url(post.projects.view, name=str(project.key)),
         form_classes="",
@@ -393,7 +393,7 @@ def _render_description_card(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
-def _render_finish_form(project: sql.Project) -> htm.Element:
+async def _render_finish_form(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Finish options"]
@@ -415,7 +415,7 @@ def _render_finish_form(project: sql.Project) -> htm.Element:
     )
 
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.FinishPolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
@@ -609,14 +609,14 @@ async def _render_releases_sections(
     return sections.collect()
 
 
-def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
+async def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Trusted Publishing"]
     ]
 
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.TrustedPublishingPolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
@@ -636,7 +636,7 @@ def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
     return card.collect()
 
 
-def _render_vote_form(project: sql.Project) -> htm.Element:
+async def _render_vote_form(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Vote options"]
@@ -681,7 +681,7 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
     )
 
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.VotePolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index cb6a89cd..f57b4cfe 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -108,7 +108,7 @@ async def selected(
     else:
         form_cls = shared.resolve.CancelSubmitForm
 
-    resolve_form = atr.form.render(
+    resolve_form = await atr.form.render(
         model_cls=form_cls,
         action=util.as_url(post.resolve.selected, project_key=release.project.key, version_key=release.version),
         submit_label=submit_label,
diff --git a/atr/get/revisions.py b/atr/get/revisions.py
index 86056cb4..ce4deb7c 100644
--- a/atr/get/revisions.py
+++ b/atr/get/revisions.py
@@ -182,7 +182,7 @@ async def _render_page(
 
     if revision_history:
         for revision, files_diff in revision_history:
-            _render_revision_card(
+            await _render_revision_card(
                 page, revision, files_diff, latest_revision_number, phase_key, project_key, version_key
             )
     else:
@@ -212,10 +212,10 @@ def _render_phase_indicator(phase_key: str) -> htm.Element:
     return span.collect(separator=" ")
 
 
-def _render_revision_actions(body: htm.Block, revision: sql.Revision, project_key: str, version_key: str) -> None:
+async def _render_revision_actions(body: htm.Block, revision: sql.Revision, project_key: str, version_key: str) -> None:
     body.h3(".fs-6.fw-semibold.mt-3.atr-sans")["Actions"]
     body.div(".mt-3")[
-        form.render(
+        await form.render(
             model_cls=shared.revisions.SetRevisionForm,
             form_classes="",
             submit_classes="btn-sm btn-outline-danger",
@@ -226,7 +226,7 @@ def _render_revision_actions(body: htm.Block, revision: sql.Revision, project_ke
     ]
 
 
-def _render_revision_card(
+async def _render_revision_card(
     page: htm.Block,
     revision: sql.Revision,
     files_diff: "FilesDiff",
@@ -261,7 +261,7 @@ def _render_revision_card(
             is_draft = phase_key == "draft"
             revision_is_preview = revision.phase.value.lower() == "release_preview"
             if (revision.number != latest_revision_number) and (is_draft or revision_is_preview):
-                _render_revision_actions(card_body, revision, project_key, version_key)
+                await _render_revision_actions(card_body, revision, project_key, version_key)
 
 
 def _render_revision_header(revision: sql.Revision, latest_revision_number: str | None) -> htm.Element:
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index ae6ae475..6fc9fe9c 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -114,12 +114,12 @@ async def report(
             ]
             if len(augmented_bom_versions) > 0:
                 last_augmented_bom = max(augmented_bom_versions)
-        _augment_section(block, release, task_result, latest_augment, last_augmented_bom)
+        await _augment_section(block, release, task_result, latest_augment, last_augmented_bom)
 
     _conformance_section(block, task_result)
     _license_section(block, task_result)
 
-    _vulnerability_scan_section(
+    await _vulnerability_scan_section(
         block, str(project_key), str(version_key), str(file_path), task_result, osv_tasks, is_release_candidate
     )
 
@@ -130,7 +130,7 @@ async def report(
     return await template.blank("SBOM report", content=block.collect())
 
 
-def _augment_section(
+async def _augment_section(
     block: htm.Block,
     release: sql.Release,
     task_result: results.SBOMToolScore,
@@ -154,7 +154,7 @@ def _augment_section(
 
     if len(augments) == 0:
         block.p["We can attempt to augment this SBOM with additional data."]
-        form.render_block(
+        await form.render_block(
             block,
             model_cls=shared.sbom.AugmentSBOMForm,
             submit_label="Augment SBOM",
@@ -168,7 +168,7 @@ def _augment_section(
         else:
             block.p["This SBOM was augmented by ATR at revision ", htm.code[augments[-1]], "."]
             block.p["We can perform augmentation again to check for additional new data."]
-            form.render_block(
+            await form.render_block(
                 block,
                 model_cls=shared.sbom.AugmentSBOMForm,
                 submit_label="Re-augment SBOM",
@@ -661,10 +661,10 @@ def _vulnerability_results_from_scan(
     block.append(new_block)
 
 
-def _vulnerability_scan_button(block: htm.Block) -> None:
+async def _vulnerability_scan_button(block: htm.Block) -> None:
     block.p["You can perform a new vulnerability scan."]
 
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.sbom.ScanSBOMForm,
         submit_label="Scan file",
@@ -714,7 +714,7 @@ def _vulnerability_scan_results(
         _vulnerability_results_from_bom(vulns, block, scans, previous_vulns)
 
 
-def _vulnerability_scan_section(
+async def _vulnerability_scan_section(
     block: htm.Block,
     project: str,
     version: str,
@@ -747,12 +747,14 @@ def _vulnerability_scan_section(
 
     if not is_release_candidate:
         if in_progress_task is not None:
-            _vulnerability_scan_status(block, in_progress_task, project, version, file_path)
+            await _vulnerability_scan_status(block, in_progress_task, project, version, file_path)
         else:
-            _vulnerability_scan_button(block)
+            await _vulnerability_scan_button(block)
 
 
-def _vulnerability_scan_status(block: htm.Block, task: sql.Task, project: str, version: str, file_path: str) -> None:
+async def _vulnerability_scan_status(
+    block: htm.Block, task: sql.Task, project: str, version: str, file_path: str
+) -> None:
     status_text = task.status.value.replace("_", " ").capitalize()
     block.p[f"Vulnerability scan is currently {status_text.lower()}."]
     block.p["Task ID: ", htm.code[str(task.id)]]
@@ -762,4 +764,4 @@ def _vulnerability_scan_status(block: htm.Block, task: sql.Task, project: str, v
             htm.code[task.error],
             ". Additional details are unavailable from ATR.",
         ]
-        _vulnerability_scan_button(block)
+        await _vulnerability_scan_button(block)
diff --git a/atr/get/start.py b/atr/get/start.py
index 2e99cc2f..f8a2ea67 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -98,7 +98,7 @@ async def _render_page(project: sql.Project, releases: list[sql.Release]) -> htm
         htm.strong["release candidate draft"],
         ". You can then add files to this draft before promoting it for voting.",
     ]
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.start.StartReleaseForm,
         form_classes=".atr-canary.py-4.px-5.border.rounded",
diff --git a/atr/get/test.py b/atr/get/test.py
index 0c37020b..491d7d24 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -51,7 +51,7 @@ async def test_empty(_session: web.Public, _test_empty: Literal["test/empty"]) -
     if config.is_production_mode():
         return quart.abort(404)
 
-    empty_form = form.render(
+    empty_form = await form.render(
         model_cls=form.Empty,
         submit_label="Submit empty form",
         action="/test/empty",
@@ -176,13 +176,13 @@ async def test_multiple(_session: web.Public, _test_multiple: Literal["test/mult
     if config.is_production_mode():
         return quart.abort(404)
 
-    apple_form = form.render(
+    apple_form = await form.render(
         model_cls=shared.test.AppleForm,
         submit_label="Order apples",
         action="/test/multiple",
     )
 
-    banana_form = form.render(
+    banana_form = await form.render(
         model_cls=shared.test.BananaForm,
         submit_label="Order bananas",
         action="/test/multiple",
@@ -238,7 +238,7 @@ async def test_single(session: web.Public, _test_single: Literal["test/single"])
         htpy.label(class_="btn btn-outline-danger", for_="vote_2")["-1"],
     ]
 
-    single_form = form.render(
+    single_form = await form.render(
         model_cls=shared.test.SingleForm,
         submit_label="Submit",
         action="/test/single",
diff --git a/atr/get/tokens.py b/atr/get/tokens.py
index be603aa4..c0dc7299 100644
--- a/atr/get/tokens.py
+++ b/atr/get/tokens.py
@@ -46,7 +46,7 @@ async def tokens(_session: web.Committer, _tokens: Literal["tokens"]) -> str:
         token is shown only once when you create it. You can revoke tokens
         you no longer need."""
     ]
-    add_form = form.render(
+    add_form = await form.render(
         model_cls=shared.tokens.AddTokenForm,
         form_classes=".mb-0",
         submit_label="Generate token",
@@ -55,7 +55,7 @@ async def tokens(_session: web.Committer, _tokens: Literal["tokens"]) -> str:
         htm.div(".card-header")["Generate new token"],
         htm.div(".card-body")[add_form],
     ]
-    _build_tokens_table(page, tokens_list)
+    await _build_tokens_table(page, tokens_list)
 
     page.h2["JSON Web Token (JWT)"]
     jwt_section = htm.Block()
@@ -65,7 +65,7 @@ async def tokens(_session: web.Committer, _tokens: Literal["tokens"]) -> str:
         in the Authorization header as a Bearer token when invoking the
         protected endpoints."""
     ]
-    form.render_block(
+    await form.render_block(
         jwt_section,
         model_cls=form.Empty,
         action=util.as_url(post.tokens.jwt_post),
@@ -94,7 +94,7 @@ async def tokens(_session: web.Committer, _tokens: Literal["tokens"]) -> str:
     )
 
 
-def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalAccessTokenSafe]) -> None:
+async def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalAccessTokenSafe]) -> None:
     if not tokens_list:
         page.p["No tokens found."]
         return
@@ -104,7 +104,7 @@ def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalAccessT
         if not t.id:
             continue
 
-        delete_form = form.render(
+        delete_form = await form.render(
             model_cls=shared.tokens.DeleteTokenForm,
             action=util.as_url(post.tokens.tokens),
             form_classes=".mb-0",
diff --git a/atr/get/upload.py b/atr/get/upload.py
index 623524fa..a181f923 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -76,7 +76,7 @@ async def selected(
     block.h2(id="file-upload")["File upload"]
     block.p["Use this form to add files to this candidate draft."]
 
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.upload.AddFilesForm,
         submit_label="Add files",
@@ -99,7 +99,7 @@ async def selected(
         " page for this draft once the task is queued.",
     ]
 
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.upload.SvnImportForm,
         submit_label="Queue SVN import task",
diff --git a/atr/get/user.py b/atr/get/user.py
index 189b463c..01b73806 100644
--- a/atr/get/user.py
+++ b/atr/get/user.py
@@ -67,7 +67,7 @@ async def cache_get(session: web.Committer, _user_cache: Literal["user/cache"])
 
         block.h3["Delete cache"]
         block.p["Remove your cached session information:"]
-        delete_cache_form = form.render(
+        delete_cache_form = await form.render(
             model_cls=shared.user.DeleteCacheForm,
             submit_label="Delete my cache",
             submit_classes="btn-danger",
@@ -79,7 +79,7 @@ async def cache_get(session: web.Committer, _user_cache: Literal["user/cache"])
 
         block.h3["Cache current session"]
         block.p["Press the button below to cache your current session information:"]
-        cache_form = form.render(
+        cache_form = await form.render(
             model_cls=shared.user.CacheUserForm,
             submit_label="Cache me!",
             submit_classes="btn-primary",
@@ -104,7 +104,7 @@ async def preferences(session: web.Committer, _user_preferences: Literal["user/p
     block = htm.Block()
     block.h1["User preferences"]
     block.p["Select your preferences below."]
-    prefs_form = form.render(
+    prefs_form = await form.render(
         model_cls=shared.user.UserPreferencesForm,
         submit_label="Save",
         defaults={"colour_blindness_mode": existing_prefs.colour_blindness_mode.value if existing_prefs else None},
@@ -121,7 +121,7 @@ async def tally(_session: web.Committer, _user_tally: Literal["user/tally"]) ->
     block = htm.Block()
     block.h1["Vote tally"]
     block.p["Enter a lists.apache.org thread URL or thread ID to count the votes in the thread."]
-    tally_form = form.render(
+    tally_form = await form.render(
         model_cls=shared.user.TallyForm,
         submit_label="Count votes",
     )
diff --git a/atr/get/vote.py b/atr/get/vote.py
index de70497e..be0a98be 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -590,7 +590,7 @@ async def _render_vote_authenticated(
         version_key=release.version,
     )
     vote_comment_template = release.project.policy_vote_comment_template
-    cast_vote_form = form.render(
+    cast_vote_form = await form.render(
         model_cls=shared.vote.CastVoteForm,
         action=vote_action_url,
         submit_label="Submit vote",
diff --git a/atr/get/voting.py b/atr/get/voting.py
index f4bf5033..8d3f7abb 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -236,7 +236,7 @@ async def _render_page(
     else:
         skip.append("second_round_email_to")
 
-    vote_form = form.render(
+    vote_form = await form.render(
         model_cls=shared.voting.StartVotingForm,
         submit_label="Send vote email",
         cancel_url=cancel_url,
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 24fc5273..f67f872d 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -608,6 +608,16 @@ class UserSession(sqlmodel.SQLModel, table=True):
             self.email = f"{self.uid}@apache.org"
 
 
+# SessionFormError:
+class SessionFormError(sqlmodel.SQLModel, table=True):
+    sid_hash: str = sqlmodel.Field(default="", primary_key=True)
+    path: str = sqlmodel.Field(default="", primary_key=True)
+    payload: dict[str, Any] = sqlmodel.Field(
+        default_factory=dict, sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
+    )
+    cts: float = sqlmodel.Field(default=0.0, nullable=False)
+
+
 # WorkflowSSHKey:
 class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
     fingerprint: str = sqlmodel.Field(primary_key=True, index=True)
diff --git a/atr/post/user.py b/atr/post/user.py
index 53827d26..4697d4f6 100644
--- a/atr/post/user.py
+++ b/atr/post/user.py
@@ -82,7 +82,7 @@ async def tally(
     block = htm.Block()
     block.h1["Vote tally"]
 
-    tally_resubmit = form.render(
+    tally_resubmit = await form.render(
         model_cls=shared.user.TallyForm,
         submit_label="Count votes",
         defaults={"thread": tally_form.thread},
diff --git a/atr/sessions.py b/atr/sessions.py
index e9e2af17..b5c15701 100644
--- a/atr/sessions.py
+++ b/atr/sessions.py
@@ -46,6 +46,46 @@ async def deleted_or_banned(uid: str) -> None:
     invalidate_cache()
 
 
+async def form_error_pop(path: str) -> dict[str, Any]:
+    user_session = await read()
+    if user_session is None:
+        return {}
+    async with db.session() as data:
+        statement = sqlmodel.select(sql.SessionFormError).where(
+            sql.SessionFormError.sid_hash == user_session.sid_hash,
+            sql.SessionFormError.path == path,
+        )
+        result = await data.execute(statement)
+        row = result.scalar_one_or_none()
+        if row is None:
+            return {}
+        payload = dict(row.payload)
+        await data.delete(row)
+        await data.commit()
+        return payload
+
+
+async def form_error_put(path: str, payload: dict[str, Any]) -> None:
+    user_session = await read()
+    if user_session is None:
+        log.warning("form_error_put called without an active session; dropping payload")
+        return
+    now = time.time()
+    async with db.session() as data:
+        statement = sqlmodel.select(sql.SessionFormError).where(
+            sql.SessionFormError.sid_hash == user_session.sid_hash,
+            sql.SessionFormError.path == path,
+        )
+        result = await data.execute(statement)
+        row = result.scalar_one_or_none()
+        if row is None:
+            data.add(sql.SessionFormError(sid_hash=user_session.sid_hash, path=path, payload=payload, cts=now))
+        else:
+            row.payload = payload
+            row.cts = now
+        await data.commit()
+
+
 def invalidate_cache() -> None:
     quart.g._user_session = None
 
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 2b6fa858..4581c218 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -159,7 +159,7 @@ async def render_upload_page(
     page.h2["Upload a file"]
     page.p["Upload a KEYS file from your computer."]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.UploadFileForm,
         action=util.as_url(post.keys.upload),
@@ -172,7 +172,7 @@ async def render_upload_page(
     page.h2(".mt-5")["Fetch existing KEYS file"]
     page.p["Fetch the KEYS file from the ASF downloads server for the selected committee."]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.UploadRemoteForm,
         action=util.as_url(post.keys.upload),
diff --git a/atr/tasks/maintenance.py b/atr/tasks/maintenance.py
index bc102751..333bfa47 100644
--- a/atr/tasks/maintenance.py
+++ b/atr/tasks/maintenance.py
@@ -15,12 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import time
+from typing import Final
+
+import sqlmodel
+
+import atr.db as db
 import atr.log as log
 import atr.models.args as args
 import atr.models.results as results
+import atr.models.sql as sql
 import atr.tasks as tasks
 import atr.tasks.checks as checks
 
+_FORM_ERROR_TTL_SECONDS: Final[int] = 24 * 60 * 60
+
 
 class MaintenanceError(Exception):
     pass
@@ -33,6 +42,7 @@ async def run(task_args: args.MaintenanceArgs) -> results.Results | None:
 
     try:
         await _storage_maintenance()
+        await _session_data_maintenance()
 
         log.info(
             "Storage maintenance completed successfully",
@@ -49,5 +59,20 @@ async def run(task_args: args.MaintenanceArgs) -> results.Results | None:
         raise MaintenanceError(error_msg) from e
 
 
+async def _session_data_maintenance() -> None:
+    via = sql.validate_instrumented_attribute
+    cutoff = time.time() - _FORM_ERROR_TTL_SECONDS
+    async with db.session() as data:
+        active_sids = sqlmodel.select(via(sql.UserSession.sid_hash))
+        statement = sqlmodel.delete(sql.SessionFormError).where(
+            sqlmodel.or_(
+                via(sql.SessionFormError.sid_hash).not_in(active_sids),
+                via(sql.SessionFormError.cts) < cutoff,
+            )
+        )
+        await data.execute(statement)
+        await data.commit()
+
+
 async def _storage_maintenance():
     pass
diff --git a/atr/templates/macros/flash.html b/atr/templates/macros/flash.html
index f080cd2d..18be04ba 100644
--- a/atr/templates/macros/flash.html
+++ b/atr/templates/macros/flash.html
@@ -2,12 +2,10 @@
   {% with messages = get_flashed_messages(with_categories=true) %}
     {% if messages %}
       {% for category, message in messages %}
-        {% if category != 'form-error-data' %}
-          <div class="flash-message flash-{{ category }}">
-            {% if category == 'error' %}<strong>Error:</strong>{% endif %}
-            {{ message }}
-          </div>
-        {% endif %}
+        <div class="flash-message flash-{{ category }}">
+          {% if category == 'error' %}<strong>Error:</strong>{% endif %}
+          {{ message }}
+        </div>
       {% endfor %}
     {% endif %}
   {% endwith %}
diff --git a/atr/web.py b/atr/web.py
index 4512a9d5..288698c6 100644
--- a/atr/web.py
+++ b/atr/web.py
@@ -17,7 +17,6 @@
 
 from __future__ import annotations
 
-import json
 import urllib.parse
 from typing import TYPE_CHECKING, Any, Protocol, TypeVar
 
@@ -33,6 +32,7 @@ import atr.form as form
 import atr.htm as htm
 import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.sessions as sessions
 import atr.user as user
 import atr.util as util
 
@@ -132,7 +132,7 @@ class Committer:
         summary = form.flash_error_summary(errors, flash_data)
 
         await quart.flash(summary, category="error")
-        await quart.flash(json.dumps(flash_data), category="form-error-data")
+        await sessions.form_error_put(quart.request.path, flash_data)
         return quart.redirect(quart.request.path)
 
     async def form_validate(self, form_cls: type[form.Form], context: dict[str, Any]) -> pydantic.BaseModel:
diff --git a/migrations/versions/0066_2026.04.13_a7d14b22.py b/migrations/versions/0066_2026.04.13_a7d14b22.py
new file mode 100644
index 00000000..785bae1e
--- /dev/null
+++ b/migrations/versions/0066_2026.04.13_a7d14b22.py
@@ -0,0 +1,32 @@
+"""Add sessionformerror table for server-side form error flashes
+
+Revision ID: 0066_2026.04.13_a7d14b22
+Revises: 0065_2026.04.09_56a8188f
+Create Date: 2026-04-13 11:57:02.301243+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0066_2026.04.13_a7d14b22"
+down_revision: str | None = "0065_2026.04.09_56a8188f"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "sessionformerror",
+        sa.Column("sid_hash", sa.String(), nullable=False),
+        sa.Column("path", sa.String(), nullable=False),
+        sa.Column("payload", sa.JSON(), nullable=False),
+        sa.Column("cts", sa.Float(), nullable=False),
+        sa.PrimaryKeyConstraint("sid_hash", "path", name=op.f("pk_sessionformerror")),
+    )
+
+
+def downgrade() -> None:
+    op.drop_table("sessionformerror")
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 31cb2f6e..fdd21c4c 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -31,6 +31,7 @@ import atr.htm as htm
 import atr.models.results as results
 import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.sessions as sessions
 import atr.storage as storage
 import atr.storage.writers.vote as vote
 
@@ -272,6 +273,11 @@ async def test_manual_resolve_page_explains_cancellation_notice_url(
         lambda _endpoint, **_kwargs: "/vote/project/1.0.0",
     )
 
+    async def _no_form_errors(_path: str) -> dict[str, object]:
+        return {}
+
+    monkeypatch.setattr(sessions, "form_error_pop", _no_form_errors)
+
     release = SimpleNamespace(
         project=SimpleNamespace(key="project"),
         version="1.0.0",
@@ -279,7 +285,7 @@ async def test_manual_resolve_page_explains_cancellation_notice_url(
     )
 
     async with render_app.test_request_context("/manual/resolve/project/1.0.0"):
-        html = str(manual._render_resolve_page(release))
+        html = str(await manual._render_resolve_page(release))
 
     assert "manual vote resolution" in html
     assert "where you posted the result" in html


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org