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

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

commit 9b70aadb4a45c6f83a2893650cf0d2217ac04db5
Author: Alastair McFarlane <[email protected]>
AuthorDate: Wed Feb 25 11:56:34 2026 +0000

    First cut of taint tracking types for project and version
---
 atr/blueprints/get.py   | 177 ++++++++++++++++++++++++++++++++++++++++++++----
 atr/blueprints/post.py  |  36 +++++-----
 atr/cache.py            |  62 +++++++++++++++++
 atr/get/announce.py     |  37 ++++++----
 atr/get/checklist.py    |  25 ++++---
 atr/get/checks.py       |  46 ++++++++-----
 atr/get/committees.py   |  17 +++--
 atr/get/compose.py      |  23 +++++--
 atr/get/distribution.py | 100 +++++++++++++++++++--------
 atr/get/docs.py         |   8 ++-
 atr/get/download.py     |   8 +--
 atr/get/projects.py     |   2 +-
 atr/get/ref.py          |   2 +-
 atr/get/release.py      |   4 +-
 atr/get/root.py         |   6 +-
 atr/get/test.py         |  10 +--
 atr/get/vote.py         |   2 +-
 atr/models/safe.py      |  62 +++++++++++++++++
 atr/models/unsafe.py    |  28 ++++++++
 atr/post/test.py        |   6 +-
 atr/server.py           |   4 ++
 atr/shared/web.py       |  16 ++---
 atr/web.py              |   8 ++-
 23 files changed, 546 insertions(+), 143 deletions(-)

diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py
index f715defd..707320bd 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -15,18 +15,23 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import inspect
 import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
-from typing import Any
+from typing import Any, Literal, get_args, get_origin, get_type_hints
 
 import asfquart.auth as auth
 import asfquart.base as base
 import asfquart.session
 import quart
 
+import atr.cache as cache
+import atr.db as db
 import atr.ldap as ldap
 import atr.log as log
+import atr.models.safe as safe
+import atr.models.sql as sql
 import atr.web as web
 
 _BLUEPRINT_NAME = "get_blueprint"
@@ -34,17 +39,163 @@ _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
 _routes: list[str] = []
 
 
+async def _authenticate() -> web.Committer:
+    web_session = await asfquart.session.read()
+    if web_session is None:
+        raise base.ASFQuartException("Not authenticated", errorcode=401)
+    if (web_session.uid is None) or (not await 
ldap.is_active(web_session.uid)):
+        asfquart.session.clear()
+        raise base.ASFQuartException("Account is disabled", errorcode=401)
+    return web.Committer(web_session)
+
+
+async def _authenticate_public() -> web.Public:
+    web_session = await asfquart.session.read()
+    if web_session is None:
+        return None
+    else:
+        return await _authenticate()
+
+
+def _register(func: Callable[..., Any]) -> None:
+    module_name = func.__module__.split(".")[-1]
+    _routes.append(f"get.{module_name}.{func.__name__}")
+
+
+async def _validate_project(raw: str) -> safe.ProjectName:
+    if cache.project_version_has_project(raw):
+        return safe.ProjectName(raw)
+    async with db.session() as data:
+        project = await data.project(name=raw, 
status=sql.ProjectStatus.ACTIVE, _committee=False).get()
+    if project is None:
+        raise base.ASFQuartException(f"Project {raw!r} not found", 
errorcode=404)
+    return safe.ProjectName(project.name)
+
+
+async def _validate_version(project_name: safe.ProjectName, raw: str) -> 
safe.VersionName:
+    if cache.project_version_has_version(project_name, raw):
+        return safe.VersionName(raw)
+    async with db.session() as data:
+        release = await data.release(
+            project_name=str(project_name),
+            version=raw,
+            _project=False,
+            _committee=False,
+        ).get()
+    if release is None:
+        raise base.ASFQuartException(f"Version {raw!r} not found for project 
{project_name!s}", errorcode=404)
+    return safe.VersionName(release.version)
+
+
+_QUART_CONVERTERS: dict[type, str] = {
+    int: "int",
+    float: "float",
+}
+
+_VALIDATED_TYPES: set[Any] = {safe.ProjectName, safe.VersionName}
+
+
+def _build_path(func: Callable[..., Any]) -> tuple[str, list[tuple[str, 
type]], dict[str, str], bool]:
+    """Inspect a function's type hints to build a URL path and a validation 
plan.
+
+    Returns (path, validated_params, literal_params, public) where 
validated_params is a
+    list of (param_name, param_type) for each parameter that needs async
+    validation, literal_params maps parameter names to their values, and 
public is whether the route is public or not
+    """
+    hints = get_type_hints(func, include_extras=True)
+    params = list(inspect.signature(func).parameters.keys())
+    public = False
+    segments: list[str] = []
+    validated_params: list[tuple[str, type]] = []
+    literal_params: dict[str, str] = {}
+
+    for ix, param_name in enumerate(params):
+        hint = hints.get(param_name)
+
+        # This is the session object, which should be first
+        if param_name == "session":
+            if ix != 0:
+                raise TypeError(f"Parameter {param_name!r} in {func.__name__} 
must be first")
+            public = hint is web.Public
+            continue
+        if hint is None:
+            raise TypeError(f"Parameter {param_name!r} in {func.__name__} has 
no type annotation")
+
+        origin = get_origin(hint)
+
+        if origin is Literal:
+            literal_value = get_args(hint)[0]
+            segments.append(str(literal_value))
+            literal_params[param_name] = str(literal_value)
+        elif hint in _VALIDATED_TYPES:
+            segments.append(f"<{param_name}>")
+            validated_params.append((param_name, hint))
+        elif hint in _QUART_CONVERTERS:
+            converter = _QUART_CONVERTERS[hint]
+            segments.append(f"<{converter}:{param_name}>")
+        elif hint is str:
+            segments.append(f"<{param_name}>")
+        else:
+            raise TypeError(f"Parameter {param_name!r} in {func.__name__} has 
unsupported type {hint!r}")
+
+    path = "/" + "/".join(segments)
+    return path, validated_params, literal_params, public
+
+
+async def _run_validators(kwargs: dict[str, Any], validated_params: 
list[tuple[str, type]]) -> None:
+    """Validate URL parameters in order, using the cache/DB validators."""
+    for param_name, param_type in validated_params:
+        raw = kwargs[param_name]
+        if param_type is safe.ProjectName:
+            kwargs[param_name] = await _validate_project(raw)
+        elif param_type is safe.VersionName:
+            project_name = kwargs.get("project_name", "")
+            kwargs[param_name] = await _validate_version(project_name, raw)
+
+
+def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
+    """Decorator that derives the URL path from the function's type 
annotations.
+
+    - Literal["..."] parameters become literal path segments
+    - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
+    - int, float use Quart's built-in type converters
+    - str parameters pass through as-is
+    """
+    path, validated_params, literal_params, public = _build_path(func)
+
+    async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+        enhanced_session = await _authenticate() if public else None
+        await _run_validators(kwargs, validated_params)
+        kwargs.update(literal_params)
+
+        start_time_ns = time.perf_counter_ns()
+        response = await func(enhanced_session, **kwargs)
+        end_time_ns = time.perf_counter_ns()
+        total_ns = end_time_ns - start_time_ns
+        total_ms = total_ns // 1_000_000
+
+        log.performance(
+            f"GET {path} {func.__name__} = 0 0 {total_ms}",
+        )
+
+        return response
+
+    endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+    wrapper.__name__ = func.__name__
+    wrapper.__doc__ = func.__doc__
+    wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+    decorated = auth.require(auth.Requirements.committer)(wrapper)
+    _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["GET"])
+    _register(func)
+
+    return decorated
+
+
 def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], 
web.RouteFunction[Any]]:
     def decorator(func: web.CommitterRouteFunction[Any]) -> 
web.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            if web_session is None:
-                raise base.ASFQuartException("Not authenticated", 
errorcode=401)
-            if (web_session.uid is None) or (not await 
ldap.is_active(web_session.uid)):
-                asfquart.session.clear()
-                raise base.ASFQuartException("Account is disabled", 
errorcode=401)
-
-            enhanced_session = web.Committer(web_session)
+            enhanced_session = await _authenticate()
             start_time_ns = time.perf_counter_ns()
             response = await func(enhanced_session, *args, **kwargs)
             end_time_ns = time.perf_counter_ns()
@@ -65,9 +216,7 @@ def committer(path: str) -> 
Callable[[web.CommitterRouteFunction[Any]], web.Rout
 
         decorated = auth.require(auth.Requirements.committer)(wrapper)
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["GET"])
-
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"get.{module_name}.{func.__name__}")
+        _register(func)
 
         return decorated
 
@@ -87,9 +236,7 @@ def public(path: str) -> Callable[[Callable[..., 
Awaitable[Any]]], web.RouteFunc
         wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["GET"])
-
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"get.{module_name}.{func.__name__}")
+        _register(func)
 
         return wrapper
 
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index 8c9133a0..ac20af18 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -37,17 +37,25 @@ _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
 _routes: list[str] = []
 
 
+async def _authenticate() -> web.Committer:
+    web_session = await asfquart.session.read()
+    if web_session is None:
+        raise base.ASFQuartException("Not authenticated", errorcode=401)
+    if (web_session.uid is None) or (not await 
ldap.is_active(web_session.uid)):
+        asfquart.session.clear()
+        raise base.ASFQuartException("Account is disabled", errorcode=401)
+    return web.Committer(web_session)
+
+
+def _register(func: Callable[..., Any]) -> None:
+    module_name = func.__module__.split(".")[-1]
+    _routes.append(f"post.{module_name}.{func.__name__}")
+
+
 def committer(path: str) -> Callable[[web.CommitterRouteFunction[Any]], 
web.RouteFunction[Any]]:
     def decorator(func: web.CommitterRouteFunction[Any]) -> 
web.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            if web_session is None:
-                raise base.ASFQuartException("Not authenticated", 
errorcode=401)
-            if (web_session.uid is None) or (not await 
ldap.is_active(web_session.uid)):
-                asfquart.session.clear()
-                raise base.ASFQuartException("Account is disabled", 
errorcode=401)
-
-            enhanced_session = web.Committer(web_session)
+            enhanced_session = await _authenticate()
             start_time_ns = time.perf_counter_ns()
             response = await func(enhanced_session, *args, **kwargs)
             end_time_ns = time.perf_counter_ns()
@@ -68,9 +76,7 @@ def committer(path: str) -> 
Callable[[web.CommitterRouteFunction[Any]], web.Rout
 
         decorated = auth.require(auth.Requirements.committer)(wrapper)
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["POST"])
-
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"post.{module_name}.{func.__name__}")
+        _register(func)
 
         return decorated
 
@@ -82,7 +88,7 @@ def empty() -> Callable[[Callable[..., Awaitable[Any]]], 
Callable[..., Awaitable
     #
     # @post.form(form.Empty)
     # async def test_empty(
-    #     session: web.Committer | None,
+    #     session: web.Public,
     #     form: form.Empty,
     # ) -> web.WerkzeugResponse:
     #     pass
@@ -91,7 +97,7 @@ def empty() -> Callable[[Callable[..., Awaitable[Any]]], 
Callable[..., Awaitable
     #
     # @post.empty()
     # async def test_empty(
-    #     session: web.Committer | None,
+    #     session: web.Public,
     # ) -> web.WerkzeugResponse:
     #     pass
     def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., 
Awaitable[Any]]:
@@ -187,9 +193,7 @@ def public(path: str) -> Callable[[Callable[..., 
Awaitable[Any]]], web.RouteFunc
         wrapper.__name__ = func.__name__
 
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["POST"])
-
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"post.{module_name}.{func.__name__}")
+        _register(func)
 
         return wrapper
 
diff --git a/atr/cache.py b/atr/cache.py
index f652a02d..e578f0aa 100644
--- a/atr/cache.py
+++ b/atr/cache.py
@@ -27,11 +27,14 @@ import pydantic
 import atr.config as config
 import atr.ldap as ldap
 import atr.log as log
+import atr.models.safe as safe
 import atr.models.schema as schema
 
 # Fifth prime after 3600
 ADMINS_POLL_INTERVAL_SECONDS: Final[int] = 3631
 
+PROJECT_VERSION_POLL_INTERVAL_SECONDS: Final[int] = 307
+
 
 class AdminsCache(schema.Strict):
     refreshed: datetime.datetime = schema.description("When the cache was last 
refreshed")
@@ -92,6 +95,39 @@ async def admins_startup_load() -> None:
         log.warning(f"Failed to fetch admin users from LDAP at startup: {e}")
 
 
+def project_version_get() -> dict[str, set[str]]:
+    if asfquart.APP is not None:
+        return asfquart.APP.extensions.get("project_versions", {})
+    return {}
+
+
+def project_version_has_project(project_name: str) -> bool:
+    return project_name in project_version_get()
+
+
+def project_version_has_version(project_name: safe.ProjectName, version_name: 
str) -> bool:
+    projects = project_version_get()
+    if str(project_name) not in projects:
+        return False
+    return version_name in projects[str(project_name)]
+
+
+async def project_version_refresh_loop() -> None:
+    while True:
+        await asyncio.sleep(PROJECT_VERSION_POLL_INTERVAL_SECONDS)
+        try:
+            await _project_version_refresh()
+        except Exception as e:
+            log.warning(f"Project/version cache refresh failed: {e}")
+
+
+async def project_version_startup_load() -> None:
+    try:
+        await _project_version_refresh()
+    except Exception as e:
+        log.warning(f"Failed to populate project/version cache at startup: 
{e}")
+
+
 def _admins_path() -> pathlib.Path:
     return pathlib.Path(config.get().STATE_DIR) / "cache" / "admins.json"
 
@@ -134,3 +170,29 @@ def _admins_update_app_extensions(admins: frozenset[str]) 
-> None:
     app = asfquart.APP
     app.extensions["admins"] = admins
     app.extensions["admins_refreshed"] = datetime.datetime.now(datetime.UTC)
+
+
+async def _project_version_fetch_from_db() -> dict[str, set[str]]:
+    import atr.db as db
+    import atr.models.sql as sql
+
+    projects: dict[str, set[str]] = {}
+    async with db.session() as data:
+        all_projects = await data.project(status=sql.ProjectStatus.ACTIVE, 
_committee=False).all()
+        for project in all_projects:
+            all_releases = await data.release(project_name=project.name, 
_project=False, _committee=False).all()
+            projects[project.name] = {release.version for release in 
all_releases}
+    return projects
+
+
+async def _project_version_refresh() -> None:
+    projects = await _project_version_fetch_from_db()
+    _project_version_update_app_extensions(projects)
+    total_versions = sum(len(v) for v in projects.values())
+    log.info(f"Project/version cache refreshed: {len(projects)} projects, 
{total_versions} versions")
+
+
+def _project_version_update_app_extensions(projects: dict[str, set[str]]) -> 
None:
+    app = asfquart.APP
+    app.extensions["project_versions"] = projects
+    app.extensions["project_versions_refreshed"] = 
datetime.datetime.now(datetime.UTC)
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 235c8463..1999c05a 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -14,7 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
+from typing import Literal
 
 import htpy
 import markupsafe
@@ -26,6 +26,7 @@ import atr.construct as construct
 import atr.form as form
 import atr.get.projects as projects
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.post as post
 import atr.render as render
@@ -35,12 +36,20 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/announce/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> str | web.WerkzeugResponse:
-    """Allow the user to announce a release preview."""
-    await session.check_access(project_name)
[email protected]
+async def selected(
+    session: web.Committer,
+    _announce: Literal["announce"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str | web.WerkzeugResponse:
+    """
+    URL: /announce/<project_name>/<version_name>
+    Allow the user to announce a release preview.
+    """
+    await session.check_access(str(project_name))
 
-    release = await _get_page_data(project_name, session, version_name)
+    release = await _get_page_data(session, project_name, version_name)
 
     latest_revision_number = release.latest_revision_number
     if latest_revision_number is None:
@@ -51,16 +60,16 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
         )
 
     # Get the templates from the release policy
-    default_subject_template = await 
construct.announce_release_subject_default(project_name)
-    default_body_template = await 
construct.announce_release_default(project_name)
+    default_subject_template = await 
construct.announce_release_subject_default(str(project_name))
+    default_body_template = await 
construct.announce_release_default(str(project_name))
     subject_template_hash = construct.template_hash(default_subject_template)
 
     # Expand the templates
     options = construct.AnnounceReleaseOptions(
         asfuid=session.uid,
         fullname=session.fullname,
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         revision_number=latest_revision_number,
     )
     default_subject, default_body = await 
construct.announce_release_subject_and_body(
@@ -102,10 +111,12 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
     )
 
 
-async def _get_page_data(project_name: str, session: web.Committer, 
version_name: str) -> sql.Release:
+async def _get_page_data(
+    session: web.Committer, project_name: safe.ProjectName, version_name: 
safe.VersionName
+) -> sql.Release:
     release = await session.release(
-        project_name,
-        version_name,
+        str(project_name),
+        str(version_name),
         with_committee=True,
         phase=sql.ReleasePhase.RELEASE_PREVIEW,
         with_distributions=True,
diff --git a/atr/get/checklist.py b/atr/get/checklist.py
index d33db173..3153114e 100644
--- a/atr/get/checklist.py
+++ b/atr/get/checklist.py
@@ -14,6 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Literal
 
 import cmarkgfm
 import markupsafe
@@ -25,18 +26,24 @@ import atr.db as db
 import atr.db.interaction as interaction
 import atr.get.vote as vote
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.render as render
 import atr.template as template
 import atr.util as util
 import atr.web as web
 
 
[email protected]("/checklist/<project_name>/<version_name>")
-async def selected(session: web.Committer | None, project_name: str, 
version_name: str) -> str:
[email protected]
+async def selected(
+    session: web.Public,
+    _checklist: Literal["checklist"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
     async with db.session() as data:
         release = await data.release(
-            project_name=project_name,
-            version=version_name,
+            project_name=str(project_name),
+            version=str(version_name),
             _project=True,
             _committee=True,
             _project_release_policy=True,
@@ -60,7 +67,7 @@ async def selected(session: web.Committer | None, 
project_name: str, version_nam
     substituted_markdown = construct.checklist_body(
         checklist_markdown,
         project=project,
-        version_name=version_name,
+        version_name=str(version_name),
         committee=committee,
         revision=latest_revision,
     )
@@ -69,8 +76,8 @@ async def selected(session: web.Committer | None, 
project_name: str, version_nam
     page = htm.Block()
     render.html_nav(
         page,
-        back_url=util.as_url(vote.selected, project_name=project_name, 
version_name=version_name),
-        back_anchor=f"Vote on {project.short_display_name} {version_name}",
+        back_url=util.as_url(vote.selected, project_name=str(project_name), 
version_name=str(version_name)),
+        back_anchor=f"Vote on {project.short_display_name} {version_name!s}",
         phase="VOTE",
     )
     page.h1["Release checklist"]
@@ -78,12 +85,12 @@ async def selected(session: web.Committer | None, 
project_name: str, version_nam
         "Checklist for ",
         htm.strong[project.short_display_name],
         " version ",
-        version_name,
+        str(version_name),
         ":",
     ]
     page.div(".checklist-content.mt-4")[checklist_html]
 
     return await template.blank(
-        title=f"Release checklist for {project.short_display_name} 
{version_name}",
+        title=f"Release checklist for {project.short_display_name} 
{version_name!s}",
         content=page.collect(),
     )
diff --git a/atr/get/checks.py b/atr/get/checks.py
index 8faa5ce2..a989bf1b 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -17,7 +17,7 @@
 
 import pathlib
 from collections.abc import Callable
-from typing import NamedTuple
+from typing import Literal, NamedTuple
 
 import asfquart.base as base
 import htpy
@@ -33,6 +33,7 @@ import atr.get.report as report
 import atr.get.sbom as sbom
 import atr.get.vote as vote
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.paths as paths
 import atr.post as post
@@ -97,13 +98,18 @@ async def get_file_totals(release: sql.Release, session: 
web.Committer | None) -
     return totals
 
 
[email protected]("/checks/<project_name>/<version_name>")
-async def selected(session: web.Committer | None, project_name: str, 
version_name: str) -> str:
-    """Show the file checks for a release candidate."""
[email protected]
+async def selected(
+    session: web.Public, _checks: Literal["checks"], project_name: 
safe.ProjectName, version_name: safe.VersionName
+) -> str:
+    """
+    URL: /checks/<project_name>/<version_name>
+    Show the file checks for a release candidate.
+    """
     async with db.session() as data:
         release = await data.release(
-            project_name=project_name,
-            version=version_name,
+            project_name=str(project_name),
+            version=str(version_name),
             phase=sql.ReleasePhase.RELEASE_CANDIDATE,
             _committee=True,
         ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
@@ -134,18 +140,22 @@ async def selected(session: web.Committer | None, 
project_name: str, version_nam
     )
 
 
[email protected]("/checks/<project_name>/<version_name>/<revision_number>")
[email protected]
 async def selected_revision(
     session: web.Committer,
-    project_name: str,
-    version_name: str,
+    _checks: Literal["checks"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
     revision_number: str,
 ) -> web.QuartResponse:
-    """Return JSON with ongoing count and HTML fragments for dynamic 
updates."""
+    """
+    URL: /checks/<project_name>/<version_name>/<revision_number>
+    Return JSON with ongoing count and HTML fragments for dynamic updates.
+    """
     async with db.session() as data:
         release = await data.release(
-            project_name=project_name,
-            version=version_name,
+            project_name=str(project_name),
+            version=str(version_name),
             _committee=True,
             # _project=True is included in _project_release_policy=True
             _project_release_policy=True,
@@ -159,9 +169,9 @@ async def selected_revision(
         ragp = read.as_general_public()
         info = await ragp.releases.path_info(release, all_paths)
 
-    ongoing_count = await interaction.tasks_ongoing(project_name, 
version_name, revision_number)
+    ongoing_count = await interaction.tasks_ongoing(str(project_name), 
str(version_name), revision_number)
 
-    checks_summary_elem = shared.web._render_checks_summary(info, 
project_name, version_name)
+    checks_summary_elem = shared.web.render_checks_summary(info, project_name, 
version_name)
     checks_summary_html = str(checks_summary_elem) if checks_summary_elem else 
""
 
     delete_file_forms: dict[str, str] = {}
@@ -170,7 +180,9 @@ async def selected_revision(
             delete_file_forms[str(path)] = str(
                 form.render(
                     model_cls=draft.DeleteFileForm,
-                    action=util.as_url(post.draft.delete_file, 
project_name=project_name, version_name=version_name),
+                    action=util.as_url(
+                        post.draft.delete_file, 
project_name=str(project_name), version_name=str(version_name)
+                    ),
                     form_classes=".d-inline-block.m-0",
                     submit_classes="btn-sm btn-outline-danger",
                     submit_label="Delete",
@@ -188,8 +200,8 @@ async def selected_revision(
         "check-selected-path-table.html",
         paths=paths,
         info=info,
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         release=release,
         phase=release.phase.value,
         delete_file_forms=delete_file_forms,
diff --git a/atr/get/committees.py b/atr/get/committees.py
index a660aea8..098c1b55 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import datetime
+from typing import Literal
 
 import asfquart.base as base
 
@@ -30,9 +31,12 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/committees")
-async def directory(session: web.Committer | None) -> str:
-    """Main committee directory page."""
[email protected]
+async def directory(session: web.Public, _committees: Literal["committees"]) 
-> str:
+    """
+    URL: /committees
+    Main committee directory page.
+    """
     async with db.session() as data:
         committees = await 
data.committee(_projects=True).order_by(sql.Committee.name).all()
         return await template.render(
@@ -43,8 +47,11 @@ async def directory(session: web.Committer | None) -> str:
         )
 
 
[email protected]("/committees/<name>")
-async def view(session: web.Committer | None, name: str) -> str:
[email protected]
+async def view(session: web.Public, _committees: Literal["committees"], name: 
str) -> str:
+    """
+    URL: /committees/<name>
+    """
     # TODO: Could also import this from keys.py
     async with db.session() as data:
         committee = await data.committee(
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 6e400872..abe3e27a 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -15,25 +15,36 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import asfquart.base as base
 
 import atr.blueprints.get as get
 import atr.db as db
 import atr.mapping as mapping
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.web as web
 
 
[email protected]("/compose/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse | str:
-    """Show the contents of the release candidate draft."""
-    await session.check_access(project_name)
[email protected]
+async def selected(
+    session: web.Committer,
+    _compose: Literal["compose"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.WerkzeugResponse | str:
+    """
+    URL: /compose/<project_name>/<version_name>
+    Show the contents of the release candidate draft.
+    """
+    await session.check_access(str(project_name))
 
     async with db.session() as data:
         release = await data.release(
-            project_name=project_name,
-            version=version_name,
+            project_name=str(project_name),
+            version=str(version_name),
             _committee=True,
             _project_release_policy=True,
         ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 4bbf9225..6ce87d39 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 from collections.abc import Sequence
+from typing import Literal
 
 import asfquart.base as base
 import htpy
@@ -23,6 +24,7 @@ import atr.blueprints.get as get
 import atr.db as db
 import atr.form as form
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.post as post
 import atr.render as render
@@ -33,35 +35,51 @@ import atr.web as web
 from atr.tasks import gha
 
 
[email protected]("/distribution/automate/<project>/<version>")
-async def automate(session: web.Committer, project: str, version: str) -> str:
-    return await _automate_form_page(project, version, staging=False)
-
-
[email protected]("/distributions/list/<project_name>/<version_name>")
-async def list_get(session: web.Committer, project_name: str, version_name: 
str) -> str:
-    distributions, tasks = await _get_page_data(project_name, version_name)
[email protected]
+async def automate(
+    session: web.Committer,
+    _distribution: Literal["distribution/automate"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /distribution/automate/<project_name>/<version>
+    """
+    return await _automate_form_page(str(project_name), str(version_name), 
staging=False)
+
+
[email protected]
+async def list_get(
+    session: web.Committer,
+    _distribution: Literal["distribution/list"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /distribution/list/<project_name>/<version_name>
+    """
+    distributions, tasks = await _get_page_data(str(project_name), 
str(version_name))
 
     block = htm.Block()
 
-    release = await shared.distribution.release_validated(project_name, 
version_name, staging=None)
+    release = await shared.distribution.release_validated(str(project_name), 
str(version_name), staging=None)
     staging = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
-    render.html_nav_phase(block, project_name, version_name, staging)
+    render.html_nav_phase(block, str(project_name), str(version_name), staging)
 
     record_a_distribution = htm.a(
         ".btn.btn-primary",
         href=util.as_url(
             stage_record if staging else record,
-            project=project_name,
-            version=version_name,
+            project=str(project_name),
+            version=str(version_name),
         ),
     )["Record a distribution"]
 
     # Distribution list for project-version
-    block.h1["Distribution list for ", 
htm.em[f"{project_name}-{version_name}"]]
+    block.h1["Distribution list for ", 
htm.em[f"{project_name!s}-{version_name!s}"]]
 
     if len(tasks) > 0:
-        _render_distribution_tasks(tasks, block, project_name, version_name)
+        _render_distribution_tasks(tasks, block, str(project_name), 
str(version_name))
 
     if not distributions:
         block.p["No distributions found."]
@@ -102,7 +120,7 @@ async def list_get(session: web.Committer, project_name: 
str, version_name: str)
 
         delete_form = form.render(
             model_cls=shared.distribution.DeleteForm,
-            action=util.as_url(post.distribution.delete, project=project_name, 
version=version_name),
+            action=util.as_url(post.distribution.delete, 
project=str(project_name), version=str(version_name)),
             form_classes=".d-inline-block.m-0",
             submit_classes="btn-danger btn-sm",
             submit_label="Delete",
@@ -118,23 +136,47 @@ async def list_get(session: web.Committer, project_name: 
str, version_name: str)
         )
         block.append(htm.div(".mb-3")[delete_form])
 
-    title = f"Distribution list for {project_name} {version_name}"
+    title = f"Distribution list for {project_name!s} {version_name!s}"
     return await template.blank(title, content=block.collect())
 
 
[email protected]("/distribution/record/<project>/<version>")
-async def record(session: web.Committer, project: str, version: str) -> str:
-    return await _record_form_page(project, version, staging=False)
-
-
[email protected]("/distribution/stage/automate/<project>/<version>")
-async def stage_automate(session: web.Committer, project: str, version: str) 
-> str:
-    return await _automate_form_page(project, version, staging=True)
-
-
[email protected]("/distribution/stage/record/<project>/<version>")
-async def stage_record(session: web.Committer, project: str, version: str) -> 
str:
-    return await _record_form_page(project, version, staging=True)
[email protected]
+async def record(
+    session: web.Committer,
+    _distribution: Literal["distribution/record"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /distribution/record/<project_name>/<version_name>
+    """
+    return await _record_form_page(str(project_name), str(version_name), 
staging=False)
+
+
[email protected]
+async def stage_automate(
+    session: web.Committer,
+    _distribution: Literal["distribution/stage/automate"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /distribution/stage/automate/<project_name>/<version_name>
+    """
+    return await _automate_form_page(str(project_name), str(version_name), 
staging=True)
+
+
[email protected]
+async def stage_record(
+    session: web.Committer,
+    _distribution: Literal["distribution/stage/record"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /distribution/stage/record/<project_name>/<version_name>
+    """
+    return await _record_form_page(str(project_name), str(version_name), 
staging=True)
 
 
 async def _automate_form_page(project: str, version: str, staging: bool) -> 
str:
diff --git a/atr/get/docs.py b/atr/get/docs.py
index dfaab4aa..bf3a215e 100644
--- a/atr/get/docs.py
+++ b/atr/get/docs.py
@@ -17,6 +17,7 @@
 
 import pathlib
 from html.parser import HTMLParser
+from typing import Literal
 
 import aiofiles
 import aiofiles.os
@@ -49,13 +50,14 @@ class H1Parser(HTMLParser):
             self.h1_content = data.strip()
 
 
[email protected]("/docs/")
-async def index(session: web.Committer | None) -> str:
[email protected]
+async def index(session: web.Public, _docs: Literal["/docs/"]) -> str:
     return await _serve_docs_page("index")
 
 
+# TODO: This one needs path which my current decorator doesn't support...
 @get.public("/docs/<path:page>")
-async def page(session: web.Committer | None, page: str) -> str:
+async def page(session: web.Public, page: str) -> str:
     validated_page = form.to_relpath(page)
     if validated_page is None:
         quart.abort(400)
diff --git a/atr/get/download.py b/atr/get/download.py
index bb8d5145..c7cbeb27 100644
--- a/atr/get/download.py
+++ b/atr/get/download.py
@@ -65,19 +65,19 @@ async def all_selected(session: web.Committer, 
project_name: str, version_name:
 
 
 @get.public("/download/path/<project_name>/<version_name>/<path:file_path>")
-async def path(session: web.Committer | None, project_name: str, version_name: 
str, file_path: str) -> web.Response:
+async def path(session: web.Public, project_name: str, version_name: str, 
file_path: str) -> web.Response:
     """Download a file or list a directory from a release in any phase."""
     return await _download_or_list(project_name, version_name, file_path)
 
 
 @get.public("/download/path/<project_name>/<version_name>/")
-async def path_empty(session: web.Committer | None, project_name: str, 
version_name: str) -> web.Response:
+async def path_empty(session: web.Public, project_name: str, version_name: 
str) -> web.Response:
     """List files at the root of a release directory for download."""
     return await _download_or_list(project_name, version_name, ".")
 
 
 @get.public("/download/sh/<project_name>/<version_name>")
-async def sh_selected(session: web.Committer | None, project_name: str, 
version_name: str) -> web.Response:
+async def sh_selected(session: web.Public, project_name: str, version_name: 
str) -> web.Response:
     """Shell script to download a release."""
     conf = config.get()
     app_host = conf.APP_HOST
@@ -94,7 +94,7 @@ async def sh_selected(session: web.Committer | None, 
project_name: str, version_
 
 
 @get.public("/download/urls/<project_name>/<version_name>")
-async def urls_selected(session: web.Committer | None, project_name: str, 
version_name: str) -> web.Response:
+async def urls_selected(session: web.Public, project_name: str, version_name: 
str) -> web.Response:
     try:
         async with db.session() as data:
             release = await data.release(project_name=project_name, 
version=version_name).demand(
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 30753756..da85cbc9 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -86,7 +86,7 @@ async def add_project(session: web.Committer, committee_name: 
str) -> web.Werkze
 
 
 @get.public("/projects")
-async def projects(session: web.Committer | None) -> str:
+async def projects(session: web.Public) -> str:
     """Main project directory page."""
     async with db.session() as data:
         projects = await 
data.project(_committee=True).order_by(sql.Project.full_name).all()
diff --git a/atr/get/ref.py b/atr/get/ref.py
index fab3e898..dacac6b0 100644
--- a/atr/get/ref.py
+++ b/atr/get/ref.py
@@ -33,7 +33,7 @@ import atr.web as web
 
 
 @get.public("/ref/<path:ref_path>")
-async def resolve(session: web.Committer | None, ref_path: str) -> 
web.WerkzeugResponse:
+async def resolve(session: web.Public, ref_path: str) -> web.WerkzeugResponse:
     project_root = pathlib.Path(config.get().PROJECT_ROOT)
 
     if ":" in ref_path:
diff --git a/atr/get/release.py b/atr/get/release.py
index 10a7061b..183096e2 100644
--- a/atr/get/release.py
+++ b/atr/get/release.py
@@ -29,7 +29,7 @@ import atr.web as web
 
 
 @get.public("/releases/finished/<project_name>")
-async def finished(session: web.Committer | None, project_name: str) -> str:
+async def finished(session: web.Public, project_name: str) -> str:
     """View all finished releases for a project."""
     async with db.session() as data:
         project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE).demand(
@@ -53,7 +53,7 @@ async def finished(session: web.Committer | None, 
project_name: str) -> str:
 
 
 @get.public("/releases")
-async def releases(session: web.Committer | None) -> str:
+async def releases(session: web.Public) -> str:
     """View all releases."""
     # Releases are public, so we don't need to filter by user
     async with db.session() as data:
diff --git a/atr/get/root.py b/atr/get/root.py
index 808680f6..c67f5c40 100644
--- a/atr/get/root.py
+++ b/atr/get/root.py
@@ -65,7 +65,7 @@ async def about(session: web.Committer) -> str:
 
 
 @get.public("/")
-async def index(session: web.Committer | None) -> quart_response.Response | 
str:
+async def index(session: web.Public) -> quart_response.Response | str:
     """Show public info or an entry portal for participants."""
     session_data = await asfquart.session.read()
     if session_data:
@@ -140,12 +140,12 @@ async def index(session: web.Committer | None) -> 
quart_response.Response | str:
 
 
 @get.public("/policies")
-async def policies(session: web.Committer | None) -> str:
+async def policies(session: web.Public) -> str:
     return await template.blank("Policies", content=_POLICIES)
 
 
 @get.public("/miscellaneous/resolved.json")
-async def resolved_json(session: web.Committer | None) -> 
quart_response.Response:
+async def resolved_json(session: web.Public) -> quart_response.Response:
     json_path = pathlib.Path(config.get().PROJECT_ROOT) / "atr" / "static" / 
"json" / "resolved.json"
     async with aiofiles.open(json_path) as f:
         content = await f.read()
diff --git a/atr/get/test.py b/atr/get/test.py
index ad70cd64..668c9599 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -40,7 +40,7 @@ import atr.web as web
 
 
 @get.public("/test/empty")
-async def test_empty(session: web.Committer | None) -> str:
+async def test_empty(session: web.Public) -> str:
     empty_form = form.render(
         model_cls=form.Empty,
         submit_label="Submit empty form",
@@ -57,7 +57,7 @@ async def test_empty(session: web.Committer | None) -> str:
 
 
 @get.public("/test/login")
-async def test_login(session: web.Committer | None) -> web.WerkzeugResponse:
+async def test_login(session: web.Public) -> web.WerkzeugResponse:
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test login not enabled", errorcode=404)
 
@@ -126,7 +126,7 @@ async def test_merge(session: web.Committer, project_name: 
str, version_name: st
 
 
 @get.public("/test/multiple")
-async def test_multiple(session: web.Committer | None) -> str:
+async def test_multiple(session: web.Public) -> str:
     apple_form = form.render(
         model_cls=shared.test.AppleForm,
         submit_label="Order apples",
@@ -150,7 +150,7 @@ async def test_multiple(session: web.Committer | None) -> 
str:
 
 
 @get.public("/test/single")
-async def test_single(session: web.Committer | None) -> str:
+async def test_single(session: web.Public) -> str:
     import htpy
 
     vote_widget = htpy.div(class_="btn-group", role="group")[
@@ -178,7 +178,7 @@ async def test_single(session: web.Committer | None) -> str:
 
 
 @get.public("/test/vote/<category>/<project_name>/<version_name>")
-async def test_vote(session: web.Committer | None, category: str, 
project_name: str, version_name: str) -> str:
+async def test_vote(session: web.Public, category: str, project_name: str, 
version_name: str) -> str:
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test routes not enabled", errorcode=404)
 
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 4fa1df28..ceea95e9 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -172,7 +172,7 @@ async def render_vote_closed_page(release: sql.Release) -> 
str:
 
 
 @get.public("/vote/<project_name>/<version_name>")
-async def selected(session: web.Committer | None, project_name: str, 
version_name: str) -> web.WerkzeugResponse | str:
+async def selected(session: web.Public, project_name: str, version_name: str) 
-> web.WerkzeugResponse | str:
     """Show voting options for a release candidate."""
     user_category, release, latest_vote_task = await 
category_and_release(session, project_name, version_name)
 
diff --git a/atr/models/safe.py b/atr/models/safe.py
new file mode 100644
index 00000000..c6f9893b
--- /dev/null
+++ b/atr/models/safe.py
@@ -0,0 +1,62 @@
+# 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.
+
+
+class ProjectName:
+    """A project name that has been validated against the cache or database."""
+
+    __slots__ = ("_value",)
+
+    def __init__(self, value: str) -> None:
+        self._value = value
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, ProjectName):
+            return self._value == other._value
+        return NotImplemented
+
+    def __hash__(self) -> int:
+        return hash(self._value)
+
+    def __repr__(self) -> str:
+        return f"ProjectName({self._value!r})"
+
+    def __str__(self) -> str:
+        return self._value
+
+
+class VersionName:
+    """A version name that has been validated against the cache or database."""
+
+    __slots__ = ("_value",)
+
+    def __init__(self, value: str) -> None:
+        self._value = value
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, VersionName):
+            return self._value == other._value
+        return NotImplemented
+
+    def __hash__(self) -> int:
+        return hash(self._value)
+
+    def __repr__(self) -> str:
+        return f"VersionName({self._value!r})"
+
+    def __str__(self) -> str:
+        return self._value
diff --git a/atr/models/unsafe.py b/atr/models/unsafe.py
new file mode 100644
index 00000000..55174c0d
--- /dev/null
+++ b/atr/models/unsafe.py
@@ -0,0 +1,28 @@
+# 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.
+
+
+class UnsafeStr:
+    """A raw string from URL routing that has not been validated."""
+
+    __slots__ = ("_value",)
+
+    def __init__(self, value: str) -> None:
+        self._value = value
+
+    def __repr__(self) -> str:
+        return f"UnsafeStr({self._value!r})"
diff --git a/atr/post/test.py b/atr/post/test.py
index b64a9486..b2945df6 100644
--- a/atr/post/test.py
+++ b/atr/post/test.py
@@ -26,7 +26,7 @@ import atr.web as web
 
 @post.public("/test/empty")
 @post.empty()
-async def test_empty(session: web.Committer | None) -> web.WerkzeugResponse:
+async def test_empty(session: web.Public) -> web.WerkzeugResponse:
     msg = "Empty form submitted successfully"
     log.info(msg)
     await quart.flash(msg, "success")
@@ -35,7 +35,7 @@ async def test_empty(session: web.Committer | None) -> 
web.WerkzeugResponse:
 
 @post.public("/test/multiple")
 @post.form(shared.test.MultipleForm)
-async def test_multiple(session: web.Committer | None, form: 
shared.test.MultipleForm) -> web.WerkzeugResponse:
+async def test_multiple(session: web.Public, form: shared.test.MultipleForm) 
-> web.WerkzeugResponse:
     match form:
         case shared.test.AppleForm() as apple:
             msg = f"Apple order received: variety={apple.variety}, 
quantity={apple.quantity}, organic={apple.organic}"
@@ -52,7 +52,7 @@ async def test_multiple(session: web.Committer | None, form: 
shared.test.Multipl
 
 @post.public("/test/single")
 @post.form(shared.test.SingleForm)
-async def test_single(session: web.Committer | None, form: 
shared.test.SingleForm) -> web.WerkzeugResponse:
+async def test_single(session: web.Public, form: shared.test.SingleForm) -> 
web.WerkzeugResponse:
     file_names = [f.filename for f in form.files] if form.files else []
     compatibility_names = [f.value for f in form.compatibility] if 
form.compatibility else []
     if (form.message == "Forbidden message!") and (session is not None):
diff --git a/atr/server.py b/atr/server.py
index 6dcee179..fe73716a 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -277,6 +277,10 @@ def _app_setup_lifecycle(app: base.QuartApp, app_config: 
type[config.AppConfig])
         admins_task = asyncio.create_task(cache.admins_refresh_loop())
         app.extensions["admins_task"] = admins_task
 
+        await cache.project_version_startup_load()
+        project_version_task = 
asyncio.create_task(cache.project_version_refresh_loop())
+        app.extensions["project_version_task"] = project_version_task
+
         worker_manager = manager.get_worker_manager()
         await worker_manager.start()
 
diff --git a/atr/shared/web.py b/atr/shared/web.py
index 12a3a411..640026f5 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -24,6 +24,7 @@ import atr.form as form
 import atr.get as get
 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.paths as paths
 import atr.post as post
@@ -140,7 +141,7 @@ async def check(
 
     is_local_caching = release.check_cache_key is not None
 
-    checks_summary_html = _render_checks_summary(info, release.project.name, 
release.version)
+    checks_summary_html = render_checks_summary(info, release.project.name, 
release.version)
 
     return await template.render(
         "check-selected.html",
@@ -179,12 +180,9 @@ async def check(
         checks_summary_html=checks_summary_html,
     )
 
-
-def _checker_display_name(checker: str) -> str:
-    return checker.removeprefix("atr.tasks.checks.").replace("_", " 
").replace(".", " ").title()
-
-
-def _render_checks_summary(info: types.PathInfo | None, project_name: str, 
version_name: str) -> htm.Element | None:
+def render_checks_summary(
+    info: types.PathInfo | None, project_name: safe.ProjectName, version_name: 
safe.VersionName
+) -> htm.Element | None:
     if (info is None) or (not info.checker_stats):
         return None
 
@@ -210,7 +208,7 @@ def _render_checks_summary(info: types.PathInfo | None, 
project_name: str, versi
         files_div = htm.Block(htm.div, classes=".mt-2.atr-checks-files")
         all_files = set(stat.failure_files.keys()) | 
set(stat.warning_files.keys()) | set(stat.blocker_files.keys())
         for file_path in sorted(all_files):
-            report_url = f"/report/{project_name}/{version_name}/{file_path}"
+            report_url = 
f"/report/{project_name!s}/{version_name!s}/{file_path}"
             error_count = stat.failure_files.get(file_path, 0)
             blocker_count = stat.blocker_files.get(file_path, 0)
             warning_count = stat.warning_files.get(file_path, 0)
@@ -234,6 +232,8 @@ def _render_checks_summary(info: types.PathInfo | None, 
project_name: str, versi
     card.append(body.collect())
     return card.collect()
 
+def _checker_display_name(checker: str) -> str:
+    return checker.removeprefix("atr.tasks.checks.").replace("_", " 
").replace(".", " ").title()
 
 def _warnings_from_vote_result(vote_task: sql.Task | None) -> list[str]:
     # TODO: Replace this with a schema.Strict model
diff --git a/atr/web.py b/atr/web.py
index 4d7d8c9c..e7a999f5 100644
--- a/atr/web.py
+++ b/atr/web.py
@@ -42,6 +42,7 @@ if TYPE_CHECKING:
     import pydantic
     import werkzeug.wrappers.response as response
 
+
 R = TypeVar("R", covariant=True)
 
 type WerkzeugResponse = response.Response
@@ -87,7 +88,7 @@ class Committer:
         return user.is_admin(self.uid)
 
     async def check_access(self, project_name: str) -> None:
-        if not any((p.name == project_name) for p in (await 
self.user_projects)):
+        if not any((p.name == str(project_name)) for p in (await 
self.user_projects)):
             if self.is_admin:
                 # Admins can view all projects
                 # But we must warn them when the project is not one of their 
own
@@ -180,7 +181,7 @@ class Committer:
             phase_value = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
         else:
             phase_value = phase
-        release_name = sql.release_name(project_name, version_name)
+        release_name = sql.release_name(str(project_name), str(version_name))
         if data is None:
             async with db.session() as data:
                 release = await data.release(
@@ -256,6 +257,9 @@ class HeaderValue:
         return self.__value
 
 
+type Public = Committer | None
+
+
 class RouteFunction(Protocol[R]):
     """Protocol for @app_route decorated functions."""
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to