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 ac1ff3c024a767131ba0316df2747af1be6d3202
Author: Alastair McFarlane <[email protected]>
AuthorDate: Wed Feb 25 11:56:34 2026 +0000

    Add taint tracking types to get endpoints
---
 atr/blueprints/common.py                       | 148 +++++++++++++++++++++++++
 atr/blueprints/get.py                          |  93 ++++++++--------
 atr/blueprints/post.py                         |  36 +++---
 atr/cache.py                                   |  62 +++++++++++
 atr/docs/user-interface.md                     |  18 +--
 atr/get/announce.py                            |  41 ++++---
 atr/get/checklist.py                           |  25 +++--
 atr/get/checks.py                              |  50 ++++++---
 atr/get/committees.py                          |  17 ++-
 atr/get/compose.py                             |  23 ++--
 atr/get/distribution.py                        | 100 ++++++++++++-----
 atr/get/docs.py                                |  12 +-
 atr/get/download.py                            | 112 ++++++++++++++-----
 atr/get/draft.py                               |  38 +++++--
 atr/get/file.py                                |  49 +++++---
 atr/get/finish.py                              |  28 +++--
 atr/get/ignores.py                             |  20 +++-
 atr/get/keys.py                                |  55 ++++++---
 atr/get/manual.py                              |  42 ++++---
 atr/get/projects.py                            |  43 +++++--
 atr/get/published.py                           |  24 ++--
 atr/get/ref.py                                 |  10 +-
 atr/get/release.py                             |  39 ++++---
 atr/get/report.py                              |  34 ++++--
 atr/get/result.py                              |  21 ++--
 atr/get/revisions.py                           |  26 +++--
 atr/get/root.py                                |  45 +++++---
 atr/get/sbom.py                                |  31 ++++--
 atr/get/start.py                               |  14 ++-
 atr/get/test.py                                |  71 ++++++++----
 atr/get/tokens.py                              |   9 +-
 atr/get/upload.py                              |  28 +++--
 atr/get/user.py                                |   9 +-
 atr/get/vote.py                                |  19 +++-
 atr/get/voting.py                              |  32 ++++--
 atr/models/safe.py                             |  62 +++++++++++
 atr/models/unsafe.py                           |  32 ++++++
 atr/post/projects.py                           |  38 ++++---
 atr/post/sbom.py                               |  16 +--
 atr/post/test.py                               |   6 +-
 atr/server.py                                  |   4 +
 atr/shared/web.py                              |  14 +--
 atr/templates/check-selected-path-table.html   |   2 +-
 atr/templates/check-selected-release-info.html |   2 +-
 atr/templates/check-selected.html              |   4 +-
 atr/templates/committee-directory.html         |   4 +-
 atr/templates/committee-view.html              |   2 +-
 atr/templates/includes/topnav.html             |   2 +-
 atr/templates/index-committer.html             |   2 +-
 atr/templates/projects.html                    |   4 +-
 atr/web.py                                     |   8 +-
 51 files changed, 1180 insertions(+), 446 deletions(-)

diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
new file mode 100644
index 00000000..0428b83d
--- /dev/null
+++ b/atr/blueprints/common.py
@@ -0,0 +1,148 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import inspect
+from collections.abc import Callable
+from typing import Any, Literal, get_args, get_origin, get_type_hints
+
+import asfquart.base as base
+import asfquart.session
+
+import atr.cache as cache
+import atr.db as db
+import atr.ldap as ldap
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.models.unsafe as unsafe
+import atr.web as web
+
+QUART_CONVERTERS: dict[Any, str] = {
+    int: "int",
+    float: "float",
+    unsafe.Path: "path",
+}
+
+VALIDATED_TYPES: set[Any] = {safe.ProjectName, safe.VersionName}
+
+
+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:
+        try:
+            return await authenticate()
+        except base.ASFQuartException:
+            return None
+
+
+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)
+        if hint is None:
+            raise TypeError(f"Parameter {param_name!r} in {func.__name__} has 
no type annotation")
+
+        # 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
+
+        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
+
+
+def register_route(func: Callable[..., Any], prefix: str, routes: list[str]) 
-> None:
+    module_name = func.__module__.split(".")[-1]
+    routes.append(f"{prefix}.{module_name}.{func.__name__}")
+
+
+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)
+
+
+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)
diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py
index f715defd..c978add0 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -18,82 +18,77 @@
 import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
-from typing import Any
+from typing import Any, Concatenate, ParamSpec, TypeVar, overload
 
 import asfquart.auth as auth
 import asfquart.base as base
-import asfquart.session
 import quart
 
-import atr.ldap as ldap
+import atr.blueprints.common as common
 import atr.log as log
+import atr.models.safe as safe
 import atr.web as web
 
 _BLUEPRINT_NAME = "get_blueprint"
 _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
 _routes: list[str] = []
 
+_P = ParamSpec("_P")
+_R = TypeVar("_R")
 
-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)
-            start_time_ns = time.perf_counter_ns()
-            response = await func(enhanced_session, *args, **kwargs)
-            end_time_ns = time.perf_counter_ns()
-            total_ns = end_time_ns - start_time_ns
-            total_ms = total_ns // 1_000_000
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) 
-> web.RouteFunction[R]: ...
 
-            # TODO: Make this configurable in config.py
-            log.performance(
-                f"GET {path} {func.__name__} = 0 0 {total_ms}",
-            )
 
-            return response
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Public, P], Awaitable[R]]) -> 
web.RouteFunction[R]: ...  # pyright: ignore[reportOverlappingOverload]
 
-        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"])
+def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
+    """Decorator that derives the URL path from the function's type 
annotations.
 
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"get.{module_name}.{func.__name__}")
+    - Arguments after session are joined with / to make the web path
+    - 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
+    - check_access is called automatically for committer routes with 
project_name
+    """
+    path, validated_params, literal_params, public = common.build_path(func)
+    project_name_var = next((name for name, type_name in validated_params if 
type_name is safe.ProjectName), None)
+    check_access = not public and (project_name_var is not None)
 
-        return decorated
+    async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+        enhanced_session = await common.authenticate_public() if public else 
await common.authenticate()
+        await common.run_validators(kwargs, validated_params)
+        kwargs.update(literal_params)
 
-    return decorator
+        if check_access and (enhanced_session is not None) and 
(project_name_var is not None):
+            await enhanced_session.check_access(str(kwargs[project_name_var]))
 
+        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
 
-def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], 
web.RouteFunction[Any]]:
-    def decorator(func: Callable[..., Awaitable[Any]]) -> 
web.RouteFunction[Any]:
-        async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            enhanced_session = web.Committer(web_session) if web_session else 
None
-            return await func(enhanced_session, *args, **kwargs)
+        log.performance(
+            f"GET {path} {func.__name__} = 0 0 {total_ms}",
+        )
 
-        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
-        wrapper.__name__ = func.__name__
-        wrapper.__doc__ = func.__doc__
-        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+        return response
 
-        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["GET"])
+    endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+    wrapper.__name__ = func.__name__
+    wrapper.__doc__ = func.__doc__
+    wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
-        module_name = func.__module__.split(".")[-1]
-        _routes.append(f"get.{module_name}.{func.__name__}")
+    decorated = wrapper if public else 
auth.require(auth.Requirements.committer)(wrapper)
+    _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["GET"])
+    common.register_route(func, "get", _routes)
 
-        return wrapper
-
-    return decorator
+    return decorated
 
 
 def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
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/docs/user-interface.md b/atr/docs/user-interface.md
index e5f901a2..a27b984a 100644
--- a/atr/docs/user-interface.md
+++ b/atr/docs/user-interface.md
@@ -226,21 +226,23 @@ The block class also adds a `data-src` attribute to 
elements, which records whic
 A typical route that renders UI first authenticates the user, loads data from 
the database, builds HTML using htpy, and renders it using a template. GET and 
POST requests are handled by separate routes, with form validation 
automatically handled by the [`@post.form()`](/ref/atr/blueprints/post.py:form) 
decorator. Here is a simplified example from 
[`get/keys.py`](/ref/atr/get/keys.py:add):
 
 ```python
[email protected]("/keys/add")
-async def add(session: web.Committer) -> str:
-    """Add a new public signing key to the user's account."""
[email protected]
+async def add(session: web.Committer, _keys_add: Literal["keys/add"]) -> str:
+    """
+    URL: /keys/add
+    Add a new public signing key to the user's account.
+    """
     async with storage.write() as write:
         participant_of_committees = await write.participant_of_committees()
 
     committee_choices = [(c.name, c.display_name or c.name) for c in 
participant_of_committees]
 
     page = htm.Block()
-    page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage 
keys"]]
+    page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage 
keys"],]
     page.div(".my-4")[
         htm.h1(".mb-4")["Add your OpenPGP key"],
         htm.p["Add your public key to use for signing release artifacts."],
     ]
-
     form.render_block(
         page,
         model_cls=shared.keys.AddOpenPGPKeyForm,
@@ -251,15 +253,17 @@ async def add(session: web.Committer) -> str:
             "selected_committees": committee_choices,
         },
     )
-    ...
+
     return await template.blank(
         "Add your OpenPGP key",
         content=page.collect(),
         description="Add your public signing key to your ATR account.",
+        javascripts=["keys-add-toggle"],
     )
 ```
 
-The route is decorated with 
[`@get.committer()`](/ref/atr/blueprints/get.py:committer), which handles 
authentication and provides a `session` object that is an instance of 
[`web.Committer`](/ref/atr/web.py:Committer) with a range of useful properties 
and methods.
+The route is decorated with [`@get.typed`](/ref/atr/blueprints/get.py:typed), 
which handles authentication and provides a `session` object that is an 
instance of [`web.Committer`](/ref/atr/web.py:Committer) with a range of useful 
properties and methods.
+[`@get.typed`](/ref/atr/blueprints/get.py:typed) also validates `project_name` 
and `version_name` route params, and checks that the user is authorised to 
access the project (if a project name is required).
 
 The function builds the UI using an [`htm.Block`](/ref/atr/htm.py:Block) 
object, which provides a convenient API for incrementally building HTML. The 
form is rendered directly into the block using 
[`form.render_block()`](/ref/atr/form.py:render_block), which generates all the 
necessary HTML with Bootstrap styling.
 
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 235c8463..022ef3e5 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,18 @@ 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)
-
-    release = await _get_page_data(project_name, session, version_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.
+    """
+    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 +58,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 +109,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,
@@ -123,7 +132,7 @@ def _render_body_field(default_body: str, project_name: 
str) -> htm.Element:
         rows="12",
     )[default_body]
 
-    settings_url = util.as_url(projects.view, name=project_name) + 
"#announce_release_template"
+    settings_url = util.as_url(projects.view, project_name=project_name) + 
"#announce_release_template"
     link = htm.div(".form-text.text-muted.mt-2")[
         "To edit the template, go to the ",
         htm.a(href=settings_url)["project settings"],
@@ -288,7 +297,7 @@ def _render_release_card(release: sql.Release) -> 
htm.Element:
 
 
 def _render_subject_field(default_subject: str, project_name: str) -> 
htm.Element:
-    settings_url = util.as_url(projects.view, name=project_name) + 
"#announce_release_subject"
+    settings_url = util.as_url(projects.view, project_name=project_name) + 
"#announce_release_subject"
     return htm.div[
         htpy.input(
             type="text",
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 0105d977..df41705c 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, 
str(project_name), str(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=all_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,
@@ -422,7 +434,9 @@ def _render_file_row(
         version_name=release.version,
         file_path=path_str,
     )
-    sbom_url = util.as_url(sbom.report, project=release.project.name, 
version=release.version, file_path=path_str)
+    sbom_url = util.as_url(
+        sbom.report, project_name=release.project.name, 
version_name=release.version, file_path=path_str
+    )
 
     if not has_checks_before:
         path_display = htpy.code(".text-muted")[path_str]
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..18d9ce69 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -15,25 +15,34 @@
 # 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.
+    """
     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..82017a2f 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_name=str(project_name),
+            version_name=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..1e2e5b33 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
@@ -26,6 +27,7 @@ import quart
 import atr.blueprints.get as get
 import atr.config as config
 import atr.form as form
+import atr.models.unsafe as unsafe
 import atr.template as template
 import atr.web as web
 
@@ -49,14 +51,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")
 
 
[email protected]("/docs/<path:page>")
-async def page(session: web.Committer | None, page: str) -> str:
-    validated_page = form.to_relpath(page)
[email protected]
+async def page(session: web.Public, _docs: Literal["docs"], path: unsafe.Path) 
-> str:
+    validated_page = form.to_relpath(path)
     if validated_page is None:
         quart.abort(400)
     return await _serve_docs_page(str(validated_page))
diff --git a/atr/get/download.py b/atr/get/download.py
index bb8d5145..b694bd75 100644
--- a/atr/get/download.py
+++ b/atr/get/download.py
@@ -17,6 +17,7 @@
 
 import pathlib
 from collections.abc import AsyncGenerator
+from typing import Literal
 
 import aiofiles
 import aiofiles.os
@@ -30,20 +31,32 @@ import atr.db as db
 import atr.form as form
 import atr.htm as htm
 import atr.mapping as mapping
+import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.models.unsafe as unsafe
 import atr.paths as paths
 import atr.template as template
 import atr.util as util
 import atr.web as web
 
 
[email protected]("/download/all/<project_name>/<version_name>")
-async def all_selected(session: web.Committer, project_name: str, 
version_name: str) -> web.WerkzeugResponse | str:
-    """Display download commands for a release."""
[email protected]
+async def all_selected(
+    session: web.Committer,
+    _download_all: Literal["download/all"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.WerkzeugResponse | str:
+    """
+    URL: /download/all/<project_name>/<version_name>
+    Display download commands for a release.
+    """
     import atr.get.root as root
 
     async with db.session() as data:
-        release = await session.release(project_name=project_name, 
version_name=version_name, phase=None, data=data)
+        release = await session.release(
+            project_name=str(project_name), version_name=str(version_name), 
phase=None, data=data
+        )
         if not release:
             return await session.redirect(root.index, error="Release not 
found")
         user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
@@ -52,8 +65,8 @@ async def all_selected(session: web.Committer, project_name: 
str, version_name:
 
     return await template.render(
         "download-all.html",
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         release=release,
         asf_id=session.uid,
         server_domain=session.app_host.split(":", 1)[0],
@@ -64,28 +77,53 @@ async def all_selected(session: web.Committer, 
project_name: str, version_name:
     )
 
 
[email protected]("/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:
-    """Download a file or list a directory from a release in any phase."""
-    return await _download_or_list(project_name, version_name, file_path)
-
-
[email protected]("/download/path/<project_name>/<version_name>/")
-async def path_empty(session: web.Committer | None, 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, ".")
-
-
[email protected]("/download/sh/<project_name>/<version_name>")
-async def sh_selected(session: web.Committer | None, project_name: str, 
version_name: str) -> web.Response:
-    """Shell script to download a release."""
[email protected]
+async def path(
+    session: web.Public,
+    _download_path: Literal["download/path"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    file_path: unsafe.Path,
+) -> web.Response:
+    """
+    URL: /download/path/<project_name>/<version_name>/<path:file_path>
+    Download a file or list a directory from a release in any phase.
+    """
+    return await _download_or_list(str(project_name), str(version_name), 
str(file_path))
+
+
[email protected]
+async def path_empty(
+    session: web.Public,
+    _download_path: Literal["download/path"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.Response:
+    """
+    URL: /download/path/<project_name>/<version_name>/
+    List files at the root of a release directory for download.
+    """
+    return await _download_or_list(str(project_name), str(version_name), ".")
+
+
[email protected]
+async def sh_selected(
+    session: web.Public,
+    _download_sh: Literal["download/sh"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.Response:
+    """
+    URL: /download/sh/<project_name>/<version_name>
+    Shell script to download a release.
+    """
     conf = config.get()
     app_host = conf.APP_HOST
     script_path = (pathlib.Path(__file__).parent / 
"../static/sh/download-urls.sh").resolve()
     async with aiofiles.open(script_path) as f:
         content = await f.read()
-    download_urls_selected = util.as_url(urls_selected, 
project_name=project_name, version_name=version_name)
-    download_path = util.as_url(path, project_name=project_name, 
version_name=version_name, file_path="")
+    download_urls_selected = util.as_url(urls_selected, 
project_name=str(project_name), version_name=str(version_name))
+    download_path = util.as_url(path, project_name=str(project_name), 
version_name=str(version_name), file_path="")
     curl_options = "--insecure" if util.is_dev_environment() else "--proto 
=https --tlsv1.2"
     content = content.replace("[CURL_EXTRA]", curl_options)
     content = content.replace("[URL_OF_URLS]", 
f"https://{app_host}{download_urls_selected}";)
@@ -93,11 +131,19 @@ async def sh_selected(session: web.Committer | None, 
project_name: str, version_
     return web.ShellResponse(content)
 
 
[email protected]("/download/urls/<project_name>/<version_name>")
-async def urls_selected(session: web.Committer | None, project_name: str, 
version_name: str) -> web.Response:
[email protected]
+async def urls_selected(
+    session: web.Public,
+    _download_urls: Literal["download/urls"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.Response:
+    """
+    URL: /download/urls/<project_name>/<version_name>
+    """
     try:
         async with db.session() as data:
-            release = await data.release(project_name=project_name, 
version=version_name).demand(
+            release = await data.release(project_name=str(project_name), 
version=str(version_name)).demand(
                 ValueError("Release not found")
             )
         url_list_str = await _generate_file_url_list(release)
@@ -108,10 +154,18 @@ async def urls_selected(session: web.Committer | None, 
project_name: str, versio
         return web.TextResponse(f"Internal server error: {e}", status=500)
 
 
[email protected]("/download/zip/<project_name>/<version_name>")
-async def zip_selected(session: web.Committer, project_name: str, 
version_name: str) -> web.Response:
[email protected]
+async def zip_selected(
+    session: web.Committer,
+    _download_zip: Literal["download/zip"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.Response:
+    """
+    URL: /download/zip/<project_name>/<version_name>
+    """
     try:
-        release = await session.release(project_name=project_name, 
version_name=version_name, phase=None)
+        release = await session.release(project_name=str(project_name), 
version_name=str(version_name), phase=None)
     except ValueError as e:
         return web.TextResponse(f"Error: {e}", status=404)
     except Exception as e:
diff --git a/atr/get/draft.py b/atr/get/draft.py
index 16a091ac..04ce860b 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -18,12 +18,15 @@
 from __future__ import annotations
 
 import datetime
+from typing import Literal
 
 import aiofiles.os
 import asfquart.base as base
 
 import atr.blueprints.get as get
 import atr.form as form
+import atr.models.safe as safe
+import atr.models.unsafe as unsafe
 import atr.paths as paths
 import atr.post as post
 import atr.shared as shared
@@ -32,16 +35,23 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/draft/tools/<project_name>/<version_name>/<path:file_path>")
-async def tools(session: web.Committer, project_name: str, version_name: str, 
file_path: str) -> str:
-    """Show the tools for a specific file."""
-    await session.check_access(project_name)
-
-    validated_path = form.to_relpath(file_path)
[email protected]
+async def tools(
+    session: web.Committer,
+    _draft_tools: Literal["draft/tools"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    file_path: unsafe.Path,
+) -> str:
+    """
+    URL: /draft/tools/<project_name>/<version_name>/<path:file_path>
+    Show the tools for a specific file.
+    """
+    validated_path = form.to_relpath(str(file_path))
     if validated_path is None:
         raise base.ASFQuartException("Invalid file path", errorcode=400)
 
-    release = await session.release(project_name, version_name)
+    release = await session.release(str(project_name), str(version_name))
     full_path = str(paths.release_directory(release) / validated_path)
 
     # Check that the file exists
@@ -60,7 +70,10 @@ async def tools(session: web.Committer, project_name: str, 
version_name: str, fi
     }
 
     hashgen_action = util.as_url(
-        post.draft.hashgen, project_name=project_name, 
version_name=version_name, file_path=validated_path_str
+        post.draft.hashgen,
+        project_name=str(project_name),
+        version_name=str(version_name),
+        file_path=validated_path_str,
     )
     sha512_form = form.render(
         model_cls=shared.draft.HashGen,
@@ -72,7 +85,10 @@ async def tools(session: web.Committer, project_name: str, 
version_name: str, fi
     sbom_form = form.render(
         model_cls=form.Empty,
         action=util.as_url(
-            post.draft.sbomgen, project_name=project_name, 
version_name=version_name, file_path=validated_path_str
+            post.draft.sbomgen,
+            project_name=str(project_name),
+            version_name=str(version_name),
+            file_path=validated_path_str,
         ),
         submit_label="Generate CycloneDX SBOM (.cdx.json)",
         submit_classes="btn-outline-secondary",
@@ -82,8 +98,8 @@ async def tools(session: web.Committer, project_name: str, 
version_name: str, fi
     return await template.render(
         "draft-tools.html",
         asf_id=session.uid,
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         file_path=validated_path_str,
         file_data=file_data,
         release=release,
diff --git a/atr/get/file.py b/atr/get/file.py
index 2c004b55..c0f3f9c4 100644
--- a/atr/get/file.py
+++ b/atr/get/file.py
@@ -23,7 +23,9 @@ import atr.get.compose as compose
 import atr.get.finish as finish
 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.models.unsafe as unsafe
 import atr.paths as paths
 import atr.render as render
 import atr.template as template
@@ -33,21 +35,31 @@ import atr.web as web
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
[email protected]("/file/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> str:
-    """View all the files in a release (any phase)."""
-    await session.check_access(project_name)
-
-    release = await session.release(project_name, version_name, phase=None)
[email protected]
+async def selected(
+    session: web.Committer,
+    _file: Literal["file"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /file/<project_name>/<version_name>
+    View all the files in a release (any phase).
+    """
+    release = await session.release(str(project_name), str(version_name), 
phase=None)
 
     revision_number = release.latest_revision_number
     file_stats = []
     if release.phase == sql.ReleasePhase.RELEASE:
-        file_stats = [stat async for stat in 
util.content_list(paths.get_finished_dir(), project_name, version_name)]
+        file_stats = [
+            stat async for stat in util.content_list(paths.get_finished_dir(), 
str(project_name), str(version_name))
+        ]
     elif revision_number is not None:
         file_stats = [
             stat
-            async for stat in util.content_list(paths.get_unfinished_dir(), 
project_name, version_name, revision_number)
+            async for stat in util.content_list(
+                paths.get_unfinished_dir(), str(project_name), 
str(version_name), revision_number
+            )
         ]
     else:
         raise ValueError("No revision number found for unfinished release")
@@ -125,16 +137,23 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
     return await template.blank(f"Files in {release.short_display_name}", 
content=block.collect())
 
 
[email protected]("/file/<project_name>/<version_name>/<path:file_path>")
-async def selected_path(session: web.Committer, project_name: str, 
version_name: str, file_path: str) -> str:
-    """View the content of a specific file in a release (any phase)."""
-    await session.check_access(project_name)
-
-    validated_path = form.to_relpath(file_path)
[email protected]
+async def selected_path(
+    session: web.Committer,
+    _file: Literal["file"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    file_path: unsafe.Path,
+) -> str:
+    """
+    URL: /file/<project_name>/<version_name>/<path:file_path>
+    View the content of a specific file in a release (any phase).
+    """
+    validated_path = form.to_relpath(str(file_path))
     if validated_path is None:
         raise web.FlashError("Invalid file path")
 
-    release = await session.release(project_name, version_name, phase=None)
+    release = await session.release(str(project_name), str(version_name), 
phase=None)
     _max_view_size = 512 * 1024
     full_path = paths.release_directory(release) / validated_path
     content_listing = await util.archive_listing(full_path)
diff --git a/atr/get/finish.py b/atr/get/finish.py
index ac95b2c1..7a975900 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -19,6 +19,7 @@
 import dataclasses
 import pathlib
 from collections.abc import Sequence
+from typing import Literal
 
 import aiofiles.os
 import asfquart.base as base
@@ -39,6 +40,7 @@ import atr.get.revisions as revisions
 import atr.get.root as root
 import atr.htm as htm
 import atr.mapping as mapping
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.paths as paths
 import atr.render as render
@@ -56,20 +58,26 @@ class RCTagAnalysisResult:
     total_paths: int
 
 
[email protected]("/finish/<project_name>/<version_name>")
[email protected]
 async def selected(
-    session: web.Committer, project_name: str, version_name: str
+    session: web.Committer,
+    _finish: Literal["finish"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
 ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str:
-    """Finish a release preview."""
+    """
+    URL: /finish/<project_name>/<version_name>
+    Finish a release preview.
+    """
     try:
         (release, source_files_rel, target_dirs, deletable_dirs, rc_analysis, 
tasks) = await _get_page_data(
-            project_name, version_name
+            str(project_name), str(version_name)
         )
     except ValueError:
         async with db.session() as data:
             release_fallback = await data.release(
-                project_name=project_name,
-                version=version_name,
+                project_name=str(project_name),
+                version=str(version_name),
                 _committee=True,
             ).get()
             if release_fallback:
@@ -224,16 +232,16 @@ def _render_distribution_buttons(release: sql.Release) -> 
htm.Element:
                 ".btn.btn-primary.me-2",
                 href=util.as_url(
                     distribution.automate,
-                    project=release.project.name,
-                    version=release.version,
+                    project_name=release.project.name,
+                    version_name=release.version,
                 ),
             )["Distribute"],
             htm.a(
                 ".btn.btn-secondary.me-2",
                 href=util.as_url(
                     distribution.record,
-                    project=release.project.name,
-                    version=release.version,
+                    project_name=release.project.name,
+                    version_name=release.version,
                 ),
             )["Record a manual distribution"],
         ],
diff --git a/atr/get/ignores.py b/atr/get/ignores.py
index 5c741d37..45c23c01 100644
--- a/atr/get/ignores.py
+++ b/atr/get/ignores.py
@@ -15,9 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import atr.blueprints.get as get
 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.shared as shared
@@ -27,16 +30,23 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/ignores/<project_name>")
-async def ignores(session: web.Committer, project_name: str) -> str | 
web.WerkzeugResponse:
[email protected]
+async def ignores(
+    session: web.Committer,
+    _ignores: Literal["ignores"],
+    project_name: safe.ProjectName,
+) -> str | web.WerkzeugResponse:
+    """
+    URL: /ignores/<project_name>
+    """
     async with storage.read() as read:
         ragp = read.as_general_public()
-        ignores = await ragp.checks.ignores(project_name)
+        ignores = await ragp.checks.ignores(str(project_name))
 
     content = htm.div[
         htm.h1["Ignored checks"],
-        htm.p[f"Manage ignored checks for project {project_name}."],
-        _add_ignore(project_name),
+        htm.p[f"Manage ignored checks for project {project_name!s}."],
+        _add_ignore(str(project_name)),
         _existing_ignores(ignores),
     ]
 
diff --git a/atr/get/keys.py b/atr/get/keys.py
index 16da02db..b82c1b7f 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -17,6 +17,7 @@
 
 
 import datetime
+from typing import Literal
 
 import htpy
 import quart
@@ -34,9 +35,12 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/keys/add")
-async def add(session: web.Committer) -> str:
-    """Add a new public signing key to the user's account."""
[email protected]
+async def add(session: web.Committer, _keys_add: Literal["keys/add"]) -> str:
+    """
+    URL: /keys/add
+    Add a new public signing key to the user's account.
+    """
     async with storage.write() as write:
         participant_of_committees = await write.participant_of_committees()
 
@@ -67,9 +71,12 @@ async def add(session: web.Committer) -> str:
     )
 
 
[email protected]("/keys/details/<fingerprint>")
-async def details(session: web.Committer, fingerprint: str) -> str:
-    """Display details for a specific OpenPGP key."""
[email protected]
+async def details(session: web.Committer, _keys_details: 
Literal["keys/details"], fingerprint: str) -> str:
+    """
+    URL: /keys/details/<fingerprint>
+    Display details for a specific OpenPGP key.
+    """
     fingerprint = fingerprint.lower()
     async with db.session() as data:
         key, is_owner = await _key_and_is_owner(data, session, fingerprint)
@@ -173,9 +180,12 @@ async def details(session: web.Committer, fingerprint: 
str) -> str:
     )
 
 
[email protected]("/keys/export/<committee_name>")
-async def export(session: web.Committer, committee_name: str) -> 
web.TextResponse:
-    """Export a KEYS file for a specific committee."""
[email protected]
+async def export(session: web.Committer, _keys_export: Literal["keys/export"], 
committee_name: str) -> web.TextResponse:
+    """
+    URL: /keys/export/<committee_name>
+    Export a KEYS file for a specific committee.
+    """
     async with storage.write() as write:
         wafc = write.as_foundation_committer()
         keys_file_text = await wafc.keys.keys_file_text(committee_name)
@@ -183,9 +193,12 @@ async def export(session: web.Committer, committee_name: 
str) -> web.TextRespons
     return web.TextResponse(keys_file_text)
 
 
[email protected]("/keys")
-async def keys(session: web.Committer) -> str:
-    """View all keys associated with the user's account."""
[email protected]
+async def keys(session: web.Committer, _keys: Literal["keys"]) -> str:
+    """
+    URL: /keys
+    View all keys associated with the user's account.
+    """
     committees_to_query = list(set(session.committees + session.projects))
 
     async with db.session() as data:
@@ -220,9 +233,12 @@ async def keys(session: web.Committer) -> str:
     )
 
 
[email protected]("/keys/ssh/add")
-async def ssh_add(session: web.Committer) -> str:
-    """Add a new SSH key to the user's account."""
[email protected]
+async def ssh_add(session: web.Committer, _keys_ssh_add: 
Literal["keys/ssh/add"]) -> str:
+    """
+    URL: /keys/ssh/add
+    Add a new SSH key to the user's account.
+    """
     page = htm.Block()
     page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage 
keys"]]
     page.h1["Add your SSH key"]
@@ -249,9 +265,12 @@ async def ssh_add(session: web.Committer) -> str:
     )
 
 
[email protected]("/keys/upload")
-async def upload(session: web.Committer) -> str:
-    """Upload a KEYS file containing multiple OpenPGP keys."""
[email protected]
+async def upload(session: web.Committer, _keys_upload: Literal["keys/upload"]) 
-> str:
+    """
+    URL: /keys/upload
+    Upload a KEYS file containing multiple OpenPGP keys.
+    """
     return await shared.keys.render_upload_page()
 
 
diff --git a/atr/get/manual.py b/atr/get/manual.py
index ee88a1ea..76786aa2 100644
--- a/atr/get/manual.py
+++ b/atr/get/manual.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import atr.blueprints.get as get
 import atr.db as db
 import atr.db.interaction as interaction
@@ -22,6 +24,7 @@ import atr.form as form
 import atr.get.compose as compose
 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.post as post
 import atr.render as render
@@ -31,14 +34,20 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/manual/resolve/<project_name>/<version_name>")
-async def resolve_selected(session: web.Committer, project_name: str, 
version_name: str) -> str:
-    """Get the manual vote resolution page."""
-    await session.check_access(project_name)
-
[email protected]
+async def resolve_selected(
+    session: web.Committer,
+    _manual_resolve: Literal["manual/resolve"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /manual/resolve/<project_name>/<version_name>
+    Get the manual vote resolution page.
+    """
     release = await session.release(
-        project_name,
-        version_name,
+        str(project_name),
+        str(version_name),
         phase=sql.ReleasePhase.RELEASE_CANDIDATE,
         with_release_policy=True,
         with_project_release_policy=True,
@@ -55,22 +64,27 @@ async def resolve_selected(session: web.Committer, 
project_name: str, version_na
     )
 
 
[email protected]("/manual/start/<project_name>/<version_name>/<revision>")
[email protected]
 async def start_selected_revision(
-    session: web.Committer, project_name: str, version_name: str, revision: str
+    session: web.Committer,
+    _manual_start: Literal["manual/start"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    revision: str,
 ) -> web.WerkzeugResponse | str:
-    await session.check_access(project_name)
-
+    """
+    URL: /manual/start/<project_name>/<version_name>/<revision>
+    """
     async with db.session() as data:
         match await interaction.release_ready_for_vote(
-            session, project_name, version_name, revision, data, 
manual_vote=True
+            session, str(project_name), str(version_name), revision, data, 
manual_vote=True
         ):
             case str() as error:
                 return await session.redirect(
                     compose.selected,
                     error=error,
-                    project_name=project_name,
-                    version_name=version_name,
+                    project_name=str(project_name),
+                    version_name=str(version_name),
                     revision=revision,
                 )
             case (release, _committee):
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 30753756..ae2435b6 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -17,6 +17,8 @@
 
 from __future__ import annotations
 
+from typing import Literal
+
 import asfquart.base as base
 import htpy
 import strictyaml
@@ -31,6 +33,7 @@ import atr.get.committees as committees
 import atr.get.file as file
 import atr.get.start as start
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.post as post
 import atr.registry as registry
@@ -41,8 +44,13 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/project/add/<committee_name>")
-async def add_project(session: web.Committer, committee_name: str) -> 
web.WerkzeugResponse | str:
[email protected]
+async def add_project(
+    session: web.Committer, _project_add: Literal["project/add"], 
committee_name: str
+) -> web.WerkzeugResponse | str:
+    """
+    URL: /project/add/<committee_name>
+    """
     await session.check_access_committee(committee_name)
 
     async with db.session() as data:
@@ -85,9 +93,12 @@ async def add_project(session: web.Committer, 
committee_name: str) -> web.Werkze
     )
 
 
[email protected]("/projects")
-async def projects(session: web.Committer | None) -> str:
-    """Main project directory page."""
[email protected]
+async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
+    """
+    URL: /projects
+    Main project directory page.
+    """
     async with db.session() as data:
         projects = await 
data.project(_committee=True).order_by(sql.Project.full_name).all()
 
@@ -107,9 +118,12 @@ async def projects(session: web.Committer | None) -> str:
     return await template.render("projects.html", projects=projects, 
delete_forms=delete_forms)
 
 
[email protected]("/project/select")
-async def select(session: web.Committer) -> str:
-    """Select a project to work on."""
[email protected]
+async def select(session: web.Committer, _project_select: 
Literal["project/select"]) -> str:
+    """
+    URL: /project/select
+    Select a project to work on.
+    """
     user_projects = []
     if session.uid:
         async with db.session() as data:
@@ -133,12 +147,17 @@ async def select(session: web.Committer) -> str:
     return await template.render("project-select.html", 
user_projects=user_projects)
 
 
[email protected]("/projects/<name>")
-async def view(session: web.Committer, name: str) -> web.WerkzeugResponse | 
str:
[email protected]
+async def view(
+    session: web.Committer, _projects: Literal["projects"], project_name: 
safe.ProjectName
+) -> web.WerkzeugResponse | str:
+    """
+    URL: /projects/<project_name>
+    """
     async with db.session() as data:
         project = await data.project(
-            name=name, _committee=True, _committee_public_signing_keys=True, 
_release_policy=True
-        ).demand(base.ASFQuartException(f"Project {name} not found", 
errorcode=404))
+            name=str(project_name), _committee=True, 
_committee_public_signing_keys=True, _release_policy=True
+        ).demand(base.ASFQuartException(f"Project {project_name} not found", 
errorcode=404))
 
     is_committee_member = project.committee and 
(user.is_committee_member(project.committee, session.uid))
     is_privileged = session.is_admin
diff --git a/atr/get/published.py b/atr/get/published.py
index fe6dacff..b8316a14 100644
--- a/atr/get/published.py
+++ b/atr/get/published.py
@@ -18,6 +18,7 @@
 import pathlib
 import stat
 from datetime import datetime
+from typing import Literal
 
 import aiofiles.os
 import quart
@@ -25,25 +26,32 @@ import quart
 import atr.blueprints.get as get
 import atr.form as form
 import atr.htm as htm
+import atr.models.unsafe as unsafe
 import atr.paths as paths
 import atr.util as util
 import atr.web as web
 
 
[email protected]("/published/<path:path>")
-async def path(session: web.Committer, path: str) -> web.QuartResponse:
-    """View the content of a specific file in the downloads directory."""
[email protected]
+async def path(session: web.Committer, _published: Literal["published"], 
file_path: unsafe.Path) -> web.QuartResponse:
+    """
+    URL: /published/<path:file_path>
+    View the content of a specific file in the downloads directory.
+    """
     # This route is for debugging
     # When developing locally, there is no proxy to view the downloads 
directory
     # Therefore this path acts as a way to check the contents of that directory
-    validated_path = form.to_relpath(path)
+    validated_path = form.to_relpath(str(file_path))
     if validated_path is None:
         return quart.abort(400)
     return await _path(session, str(validated_path))
 
 
[email protected]("/published/")
-async def root(session: web.Committer) -> web.QuartResponse:
[email protected]
+async def root(session: web.Committer, _published: Literal["published/"]) -> 
web.QuartResponse:
+    """
+    URL: /published/
+    """
     return await _path(session, "")
 
 
@@ -63,7 +71,7 @@ async def _directory_listing_pre(full_path: pathlib.Path, 
current_path: str, pre
         parent_path = pathlib.Path(current_path).parent
         parent_url_path = str(parent_path) if (str(parent_path) != ".") else ""
         if parent_url_path:
-            pre.a(href=util.as_url(path, path=parent_url_path))["../"]
+            pre.a(href=util.as_url(path, file_path=parent_url_path))["../"]
         else:
             pre.a(href=util.as_url(root))["../"]
         pre.text("\n\n")
@@ -92,7 +100,7 @@ async def _directory_listing_pre(full_path: pathlib.Path, 
current_path: str, pre
             entry_path = str(pathlib.Path(current_path) / entry["name"])
             display_name = f"{entry['name']}/" if is_dir else entry["name"]
             pre.text(f"{mode} {nlink} {size} {mtime}  ")
-            pre.a(href=util.as_url(path, path=entry_path))[display_name]
+            pre.a(href=util.as_url(path, file_path=entry_path))[display_name]
             pre.text("\n")
 
 
diff --git a/atr/get/ref.py b/atr/get/ref.py
index fab3e898..24123181 100644
--- a/atr/get/ref.py
+++ b/atr/get/ref.py
@@ -17,6 +17,7 @@
 
 import ast
 import pathlib
+from typing import Literal
 
 import aiofiles
 import aiofiles.os
@@ -25,6 +26,7 @@ import quart
 import atr.blueprints.get as get
 import atr.config as config
 import atr.form as form
+import atr.models.unsafe as unsafe
 import atr.web as web
 
 # Perhaps GitHub will get around to implementing symbol permalinks:
@@ -32,8 +34,12 @@ import atr.web as web
 # Then this code will be easier, but we should still keep our own links
 
 
[email protected]("/ref/<path:ref_path>")
-async def resolve(session: web.Committer | None, ref_path: str) -> 
web.WerkzeugResponse:
[email protected]
+async def resolve(session: web.Public, _ref: Literal["ref"], ref_path: 
unsafe.Path) -> web.WerkzeugResponse:
+    """
+    URL: /ref/<ref_path>
+    Resolve a code reference to a GitHub permalink.
+    """
     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..114a9e3d 100644
--- a/atr/get/release.py
+++ b/atr/get/release.py
@@ -16,23 +16,30 @@
 # under the License.
 
 import datetime
+from typing import Literal
 
 import asfquart.base as base
 
 import atr.blueprints.get as get
 import atr.db as db
 import atr.db.interaction as interaction
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.template as template
 import atr.util as util
 import atr.web as web
 
 
[email protected]("/releases/finished/<project_name>")
-async def finished(session: web.Committer | None, project_name: str) -> str:
-    """View all finished releases for a project."""
[email protected]
+async def finished(
+    session: web.Public, _releases_finished: Literal["releases/finished"], 
project_name: safe.ProjectName
+) -> str:
+    """
+    URL: /releases/finished/<project_name>
+    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(
+        project = await data.project(name=str(project_name), 
status=sql.ProjectStatus.ACTIVE).demand(
             base.ASFQuartException(f"Project {project_name} not found", 
errorcode=404)
         )
 
@@ -52,9 +59,12 @@ async def finished(session: web.Committer | None, 
project_name: str) -> str:
     )
 
 
[email protected]("/releases")
-async def releases(session: web.Committer | None) -> str:
-    """View all releases."""
[email protected]
+async def releases(session: web.Public, _releases: Literal["releases"]) -> str:
+    """
+    URL: /releases
+    View all releases.
+    """
     # Releases are public, so we don't need to filter by user
     async with db.session() as data:
         releases = await data.release(
@@ -77,13 +87,16 @@ async def releases(session: web.Committer | None) -> str:
     )
 
 
[email protected]("/release/select/<project_name>")
-async def select(session: web.Committer, project_name: str) -> str:
-    """Show releases in progress for a project."""
-    await session.check_access(project_name)
-
[email protected]
+async def select(
+    session: web.Committer, _release_select: Literal["release/select"], 
project_name: safe.ProjectName
+) -> str:
+    """
+    URL: /release/select/<project_name>
+    Show releases in progress for a project.
+    """
     async with db.session() as data:
-        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE, _releases=True).demand(
+        project = await data.project(name=str(project_name), 
status=sql.ProjectStatus.ACTIVE, _releases=True).demand(
             base.ASFQuartException(f"Project {project_name} not found", 
errorcode=404)
         )
         releases = await interaction.releases_in_progress(project)
diff --git a/atr/get/report.py b/atr/get/report.py
index a3d194c9..33b99686 100644
--- a/atr/get/report.py
+++ b/atr/get/report.py
@@ -16,13 +16,16 @@
 # under the License.
 
 import datetime
+from typing import Literal
 
 import aiofiles.os
 import asfquart.base as base
 
 import atr.blueprints.get as get
 import atr.form as form
+import atr.models.safe as safe
 import atr.models.sql as sql
+import atr.models.unsafe as unsafe
 import atr.paths as paths
 import atr.storage as storage
 import atr.template as template
@@ -30,11 +33,18 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/report/<project_name>/<version_name>/<path:rel_path>")
-async def selected_path(session: web.Committer, project_name: str, 
version_name: str, rel_path: str) -> str:
-    """Show the report for a specific file."""
-    await session.check_access(project_name)
-
[email protected]
+async def selected_path(
+    session: web.Committer,
+    _report: Literal["report"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    rel_path: unsafe.Path,
+) -> str:
+    """
+    URL: /report/<project_name>/<version_name>/<rel_path>
+    Show the report for a specific file.
+    """
     validated_path = form.to_relpath(rel_path)
     if validated_path is None:
         raise base.ASFQuartException("Invalid file path", errorcode=400)
@@ -42,12 +52,16 @@ async def selected_path(session: web.Committer, 
project_name: str, version_name:
     # If the draft is not found, we try to get the release candidate
     try:
         release = await session.release(
-            project_name, version_name, with_committee=True, 
with_release_policy=True, with_project_release_policy=True
+            str(project_name),
+            str(version_name),
+            with_committee=True,
+            with_release_policy=True,
+            with_project_release_policy=True,
         )
     except base.ASFQuartException:
         release = await session.release(
-            project_name,
-            version_name,
+            str(project_name),
+            str(version_name),
             phase=sql.ReleasePhase.RELEASE_CANDIDATE,
             with_committee=True,
             with_release_policy=True,
@@ -82,8 +96,8 @@ async def selected_path(session: web.Committer, project_name: 
str, version_name:
 
     return await template.render(
         "report-selected-path.html",
-        project_name=project_name,
-        version_name=version_name,
+        project_name=str(project_name),
+        version_name=str(version_name),
         rel_path=str(validated_path),
         package=file_data,
         release=release,
diff --git a/atr/get/result.py b/atr/get/result.py
index 5fccc29c..607987bd 100644
--- a/atr/get/result.py
+++ b/atr/get/result.py
@@ -16,34 +16,39 @@
 # under the License.
 
 import json
+from typing import Literal
 
 import asfquart.base as base
 
 import atr.blueprints.get as get
 import atr.db as db
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.web as web
 
 
[email protected]("/result/data/<project_name>/<version_name>/<int:check_id>")
[email protected]
 async def data(
     session: web.Committer,
-    project_name: str,
-    version_name: str,
+    _result_data: Literal["result/data"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
     check_id: int,
 ) -> web.TextResponse:
-    """Show a check result as formatted JSON."""
+    """
+    URL: /result/data/<project_name>/<version_name>/<check_id>
+    Show a check result as formatted JSON.
+    """
     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,
         ).get()
 
         if release is None:
-            await session.check_access(project_name)
-            release = await session.release(project_name, version_name, 
with_committee=True)
+            release = await session.release(str(project_name), 
str(version_name), with_committee=True)
 
         if release.committee is None:
             raise base.ASFQuartException("Release has no committee", 
errorcode=500)
diff --git a/atr/get/revisions.py b/atr/get/revisions.py
index 680227a1..be0d7b02 100644
--- a/atr/get/revisions.py
+++ b/atr/get/revisions.py
@@ -17,6 +17,7 @@
 
 import asyncio
 import pathlib
+from typing import Literal
 
 import aiofiles.os
 import asfquart.base as base
@@ -31,6 +32,7 @@ import atr.get.compose as compose
 import atr.get.finish as finish
 import atr.get.root as root
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.models.schema as schema
 import atr.models.sql as sql
 import atr.paths as paths
@@ -47,16 +49,22 @@ class FilesDiff(schema.Strict):
     modified: list[pathlib.Path]
 
 
[email protected]("/revisions/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> str:
-    """Show the revision history for a release candidate draft or release 
preview."""
-    await session.check_access(project_name)
-
[email protected]
+async def selected(
+    session: web.Committer,
+    _revisions: Literal["revisions"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /revisions/<project_name>/<version_name>
+    Show the revision history for a release candidate draft or release preview.
+    """
     try:
-        release = await session.release(project_name, version_name)
+        release = await session.release(str(project_name), str(version_name))
         phase_key = "draft"
     except base.ASFQuartException:
-        release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_PREVIEW)
+        release = await session.release(str(project_name), str(version_name), 
phase=sql.ReleasePhase.RELEASE_PREVIEW)
         phase_key = "preview"
     release_dir = paths.release_directory_base(release)
 
@@ -96,8 +104,8 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
         phase_key,
         list(reversed(revision_history)),
         latest_revision_number,
-        project_name,
-        version_name,
+        str(project_name),
+        str(version_name),
     )
 
     return await template.blank(
diff --git a/atr/get/root.py b/atr/get/root.py
index 808680f6..38b85352 100644
--- a/atr/get/root.py
+++ b/atr/get/root.py
@@ -16,7 +16,7 @@
 # under the License.
 
 import pathlib
-from typing import Final
+from typing import Final, Literal
 
 import aiofiles
 import asfquart.session
@@ -58,15 +58,21 @@ _POLICIES: Final = htm.div[
 ]
 
 
[email protected]("/about")
-async def about(session: web.Committer) -> str:
-    """About page."""
[email protected]
+async def about(session: web.Committer, _about: Literal["about"]) -> str:
+    """
+    URL: /about
+    About page.
+    """
     return await template.render("about.html")
 
 
[email protected]("/")
-async def index(session: web.Committer | None) -> quart_response.Response | 
str:
-    """Show public info or an entry portal for participants."""
[email protected]
+async def index(session: web.Public, _root: Literal[""]) -> 
quart_response.Response | str:
+    """
+    URL: /
+    Show public info or an entry portal for participants.
+    """
     session_data = await asfquart.session.read()
     if session_data:
         uid = session_data.uid
@@ -139,20 +145,31 @@ async def index(session: web.Committer | None) -> 
quart_response.Response | str:
     return await template.render("index-public.html")
 
 
[email protected]("/policies")
-async def policies(session: web.Committer | None) -> str:
[email protected]
+async def policies(session: web.Public, _policies: Literal["policies"]) -> str:
+    """
+    URL: /policies
+    """
     return await template.blank("Policies", content=_POLICIES)
 
 
[email protected]("/miscellaneous/resolved.json")
-async def resolved_json(session: web.Committer | None) -> 
quart_response.Response:
[email protected]
+async def resolved_json(
+    session: web.Public, _miscellaneous_resolved_json: 
Literal["miscellaneous/resolved.json"]
+) -> quart_response.Response:
+    """
+    URL: /miscellaneous/resolved.json
+    """
     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()
     return quart_response.Response(content, mimetype="application/json")
 
 
[email protected]("/tutorial")
-async def tutorial(session: web.Committer) -> str:
-    """Tutorial page."""
[email protected]
+async def tutorial(session: web.Committer, _tutorial: Literal["tutorial"]) -> 
str:
+    """
+    URL: /tutorial
+    Tutorial page.
+    """
     return await template.render("tutorial.html")
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 9a16a479..277a6c46 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -31,7 +31,9 @@ import atr.get.compose as compose
 import atr.get.vote as vote
 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.models.unsafe as unsafe
 import atr.render as render
 import atr.sbom as sbom
 import atr.shared as shared
@@ -43,15 +45,24 @@ if TYPE_CHECKING:
     from collections.abc import Sequence
 
 
[email protected]("/sbom/report/<project>/<version>/<path:file_path>")
-async def report(session: web.Committer, project: str, version: str, 
file_path: str) -> str:
-    await session.check_access(project)
-
[email protected]
+async def report(
+    session: web.Committer,
+    _sbom_report: Literal["sbom/report"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    file_path: unsafe.Path,
+) -> str:
+    """
+    URL: /sbom/report/<project_name>/<version_name>/<file_path>
+    """
     # If the draft is not found, we try to get the release candidate
     try:
-        release = await session.release(project, version, with_committee=True)
+        release = await session.release(str(project_name), str(version_name), 
with_committee=True)
     except base.ASFQuartException:
-        release = await session.release(project, version, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True)
+        release = await session.release(
+            str(project_name), str(version_name), 
phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True
+        )
 
     block = htm.Block()
 
@@ -84,7 +95,9 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
         raise base.ASFQuartException("Invalid file path", errorcode=400)
     validated_path_str = str(validated_path)
 
-    task, augment_tasks, osv_tasks = await _fetch_tasks(validated_path_str, 
project, release, version)
+    task, augment_tasks, osv_tasks = await _fetch_tasks(
+        validated_path_str, str(project_name), release, str(version_name)
+    )
 
     task_status = await _report_task_results(block, task)
     if task_status:
@@ -112,7 +125,9 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
     _conformance_section(block, task_result)
     _license_section(block, task_result)
 
-    _vulnerability_scan_section(block, project, version, file_path, 
task_result, osv_tasks, is_release_candidate)
+    _vulnerability_scan_section(
+        block, str(project_name), str(version_name), str(file_path), 
task_result, osv_tasks, is_release_candidate
+    )
 
     _outdated_tool_section(block, task_result)
 
diff --git a/atr/get/start.py b/atr/get/start.py
index 25c5ffc9..b82aa0d7 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import asfquart.base as base
 import htpy
 
@@ -24,6 +26,7 @@ import atr.db.interaction as interaction
 import atr.form as form
 import atr.get.root as root
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.template as template
@@ -31,12 +34,13 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/start/<project_name>")
-async def selected(session: web.Committer, project_name: str) -> str:
-    await session.check_access(project_name)
-
[email protected]
+async def selected(session: web.Committer, _start: Literal["start"], 
project_name: safe.ProjectName) -> str:
+    """
+    URL: /start/<project_name>
+    """
     async with db.session() as data:
-        project = await data.project(name=project_name, 
status=sql.ProjectStatus.ACTIVE).demand(
+        project = await data.project(name=str(project_name), 
status=sql.ProjectStatus.ACTIVE).demand(
             base.ASFQuartException(f"Project {project_name} not found", 
errorcode=404)
         )
 
diff --git a/atr/get/test.py b/atr/get/test.py
index ad70cd64..3a6d2368 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -17,6 +17,7 @@
 
 import json
 import pathlib
+from typing import Literal
 
 import aiofiles
 import asfquart.base as base
@@ -29,6 +30,7 @@ import atr.form as form
 import atr.get.root as root
 import atr.get.vote as vote
 import atr.htm as htm
+import atr.models.safe as safe
 import atr.models.session
 import atr.models.sql as sql
 import atr.paths as paths
@@ -39,8 +41,11 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/test/empty")
-async def test_empty(session: web.Committer | None) -> str:
[email protected]
+async def test_empty(session: web.Public, _test_empty: Literal["test/empty"]) 
-> str:
+    """
+    URL: /test/empty
+    """
     empty_form = form.render(
         model_cls=form.Empty,
         submit_label="Submit empty form",
@@ -56,8 +61,11 @@ async def test_empty(session: web.Committer | None) -> str:
     return await template.blank(title="Test empty form", content=forms_html)
 
 
[email protected]("/test/login")
-async def test_login(session: web.Committer | None) -> web.WerkzeugResponse:
[email protected]
+async def test_login(session: web.Public, _test_login: Literal["test/login"]) 
-> web.WerkzeugResponse:
+    """
+    URL: /test/login
+    """
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test login not enabled", errorcode=404)
 
@@ -76,36 +84,44 @@ async def test_login(session: web.Committer | None) -> 
web.WerkzeugResponse:
     return await web.redirect(root.index)
 
 
[email protected]("/test/merge/<project_name>/<version_name>")
-async def test_merge(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse:
[email protected]
+async def test_merge(
+    session: web.Committer,
+    _test_merge: Literal["test/merge"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.WerkzeugResponse:
+    """
+    URL: /test/merge/<project_name>/<version_name>
+    """
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test routes not enabled", errorcode=404)
 
     async with storage.write(session) as write_n:
-        wacp_n = await write_n.as_project_committee_participant(project_name)
+        wacp_n = await 
write_n.as_project_committee_participant(str(project_name))
 
         async def modify_new(path_new: pathlib.Path, _old_rev_new: 
sql.Revision | None) -> None:
             async with aiofiles.open(path_new / "from_new.txt", "w") as f:
                 await f.write("new content")
 
             async with storage.write(session) as write_p:
-                wacp_p = await 
write_p.as_project_committee_participant(project_name)
+                wacp_p = await 
write_p.as_project_committee_participant(str(project_name))
 
                 async def modify_prior(path_prior: pathlib.Path, 
_old_rev_prior: sql.Revision | None) -> None:
                     async with aiofiles.open(path_prior / "from_prior.txt", 
"w") as f:
                         await f.write("prior content")
 
                 await wacp_p.revision.create_revision(
-                    project_name,
-                    version_name,
+                    str(project_name),
+                    str(version_name),
                     session.uid,
                     description="Test merge: prior revision",
                     modify=modify_prior,
                 )
 
         await wacp_n.revision.create_revision(
-            project_name,
-            version_name,
+            str(project_name),
+            str(version_name),
             session.uid,
             description="Test merge: new revision",
             modify=modify_new,
@@ -113,7 +129,7 @@ async def test_merge(session: web.Committer, project_name: 
str, version_name: st
 
     files: list[str] = []
     async with db.session() as data:
-        release_name = sql.release_name(project_name, version_name)
+        release_name = sql.release_name(str(project_name), str(version_name))
         release = await data.release(name=release_name, _project=True).demand(
             RuntimeError("Release not found after merge test")
         )
@@ -125,8 +141,11 @@ async def test_merge(session: web.Committer, project_name: 
str, version_name: st
     return response.Response(result, status=200, mimetype="application/json")
 
 
[email protected]("/test/multiple")
-async def test_multiple(session: web.Committer | None) -> str:
[email protected]
+async def test_multiple(session: web.Public, _test_multiple: 
Literal["test/multiple"]) -> str:
+    """
+    URL: /test/multiple
+    """
     apple_form = form.render(
         model_cls=shared.test.AppleForm,
         submit_label="Order apples",
@@ -149,8 +168,11 @@ async def test_multiple(session: web.Committer | None) -> 
str:
     return await template.blank(title="Test multiple forms", 
content=forms_html)
 
 
[email protected]("/test/single")
-async def test_single(session: web.Committer | None) -> str:
[email protected]
+async def test_single(session: web.Public, _test_single: 
Literal["test/single"]) -> str:
+    """
+    URL: /test/single
+    """
     import htpy
 
     vote_widget = htpy.div(class_="btn-group", role="group")[
@@ -177,8 +199,17 @@ async def test_single(session: web.Committer | None) -> 
str:
     return await template.blank(title="Test single form", content=forms_html)
 
 
[email protected]("/test/vote/<category>/<project_name>/<version_name>")
-async def test_vote(session: web.Committer | None, category: str, 
project_name: str, version_name: str) -> str:
[email protected]
+async def test_vote(
+    session: web.Public,
+    _test_vote: Literal["test/vote"],
+    category: str,
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /test/vote/<category>/<project_name>/<version_name>
+    """
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test routes not enabled", errorcode=404)
 
@@ -200,7 +231,7 @@ async def test_vote(session: web.Committer | None, 
category: str, project_name:
     if (user_category != vote.UserCategory.UNAUTHENTICATED) and (session is 
None):
         raise base.ASFQuartException("You must be logged in to preview 
authenticated views", errorcode=401)
 
-    _, release, latest_vote_task = await vote.category_and_release(session, 
project_name, version_name)
+    _, release, latest_vote_task = await vote.category_and_release(session, 
str(project_name), str(version_name))
 
     if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
         raise base.ASFQuartException("Release is not a candidate", 
errorcode=404)
diff --git a/atr/get/tokens.py b/atr/get/tokens.py
index 0f647322..388b517e 100644
--- a/atr/get/tokens.py
+++ b/atr/get/tokens.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import atr.blueprints.get as get
 import atr.form as form
 import atr.htm as htm
@@ -27,8 +29,11 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/tokens")
-async def tokens(session: web.Committer) -> str:
[email protected]
+async def tokens(session: web.Committer, _tokens: Literal["tokens"]) -> str:
+    """
+    URL: /tokens
+    """
     async with storage.read_as_foundation_committer() as rafc:
         tokens_list = await rafc.tokens.own_personal_access_tokens()
         most_recent_pat = await rafc.tokens.most_recent_jwt_pat()
diff --git a/atr/get/upload.py b/atr/get/upload.py
index 5e57258d..c4b36454 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -17,6 +17,7 @@
 
 import secrets
 from collections.abc import Sequence
+from typing import Literal
 
 import htpy
 
@@ -26,6 +27,7 @@ import atr.form as form
 import atr.get.compose as compose
 import atr.get.keys as keys
 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 +37,18 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/upload/<project_name>/<version_name>")
-async def selected(session: web.Committer, project_name: str, version_name: 
str) -> str:
-    await session.check_access(project_name)
-
[email protected]
+async def selected(
+    session: web.Committer,
+    _upload: Literal["upload"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> str:
+    """
+    URL: /upload/<project_name>/<version_name>
+    """
     async with db.session() as data:
-        release = await session.release(project_name, version_name, data=data)
+        release = await session.release(str(project_name), str(version_name), 
data=data)
         user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
 
     block = htm.Block()
@@ -70,10 +78,16 @@ async def selected(session: web.Committer, project_name: 
str, version_name: str)
 
     upload_session_token = secrets.token_hex(16)
     stage_url = util.as_url(
-        post.upload.stage, upload_session=upload_session_token, 
project_name=project_name, version_name=version_name
+        post.upload.stage,
+        upload_session=upload_session_token,
+        project_name=str(project_name),
+        version_name=str(version_name),
     )
     finalise_url = util.as_url(
-        post.upload.finalise, upload_session=upload_session_token, 
project_name=project_name, version_name=version_name
+        post.upload.finalise,
+        upload_session=upload_session_token,
+        project_name=str(project_name),
+        version_name=str(version_name),
     )
 
     block.append(
diff --git a/atr/get/user.py b/atr/get/user.py
index a28f6aa6..2f3f3541 100644
--- a/atr/get/user.py
+++ b/atr/get/user.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Literal
+
 import atr.blueprints.get as get
 import atr.form as form
 import atr.htm as htm
@@ -24,8 +26,11 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/user/cache")
-async def cache_get(session: web.Committer) -> str:
[email protected]
+async def cache_get(session: web.Committer, _user_cache: 
Literal["user/cache"]) -> str:
+    """
+    URL: /user/cache
+    """
     cache_data = await util.session_cache_read()
     user_cached = session.uid in cache_data
 
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 4fa1df28..22890514 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 
 import enum
 import urllib.parse
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
 
 import asfquart.base as base
 import htpy
@@ -35,6 +35,7 @@ import atr.get.keys as keys
 import atr.get.root as root
 import atr.htm as htm
 import atr.mapping as mapping
+import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.post as post
 import atr.render as render
@@ -171,10 +172,18 @@ async def render_vote_closed_page(release: sql.Release) 
-> str:
     )
 
 
[email protected]("/vote/<project_name>/<version_name>")
-async def selected(session: web.Committer | None, 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)
[email protected]
+async def selected(
+    session: web.Public,
+    _vote: Literal["vote"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+) -> web.WerkzeugResponse | str:
+    """
+    URL: /vote/<project_name>/<version_name>
+    Show voting options for a release candidate.
+    """
+    user_category, release, latest_vote_task = await 
category_and_release(session, str(project_name), str(version_name))
 
     if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
         if session is None:
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 665cff66..c678fbc6 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -16,6 +16,8 @@
 # under the License.
 
 
+from typing import Literal
+
 import aiofiles.os
 import htpy
 
@@ -28,6 +30,7 @@ import atr.get.compose as compose
 import atr.get.keys as keys
 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.paths as paths
 import atr.post as post
@@ -38,22 +41,27 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/voting/<project_name>/<version_name>/<revision>")
[email protected]
 async def selected_revision(
-    session: web.Committer, project_name: str, version_name: str, revision: str
+    session: web.Committer,
+    _voting: Literal["voting"],
+    project_name: safe.ProjectName,
+    version_name: safe.VersionName,
+    revision: str,
 ) -> web.WerkzeugResponse | str:
-    await session.check_access(project_name)
-
+    """
+    URL: /voting/<project_name>/<version_name>/<revision>
+    """
     async with db.session() as data:
         match await interaction.release_ready_for_vote(
-            session, project_name, version_name, revision, data, 
manual_vote=False
+            session, str(project_name), str(version_name), revision, data, 
manual_vote=False
         ):
             case str() as error:
                 return await session.redirect(
                     compose.selected,
                     error=error,
-                    project_name=project_name,
-                    version_name=version_name,
+                    project_name=str(project_name),
+                    version_name=str(version_name),
                     revision=revision,
                 )
             case (release, committee):
@@ -65,14 +73,14 @@ async def selected_revision(
         if release.release_policy and (release.release_policy.min_hours is not 
None):
             min_hours = release.release_policy.min_hours
 
-        default_subject_template = await 
construct.start_vote_subject_default(project_name)
-        default_body_template = await 
construct.start_vote_default(project_name)
+        default_subject_template = await 
construct.start_vote_subject_default(str(project_name))
+        default_body_template = await 
construct.start_vote_default(str(project_name))
         subject_template_hash = 
construct.template_hash(default_subject_template)
 
         options = construct.StartVoteOptions(
             asfuid=session.uid,
             fullname=session.fullname,
-            project_name=project_name,
+            project_name=str(project_name),
             version_name=release.version,
             revision_number=revision,
             vote_duration=min_hours,
@@ -118,7 +126,7 @@ def _render_body_field(default_body: str, project_name: 
str) -> htm.Element:
         rows="12",
     )[default_body]
 
-    settings_url = util.as_url(projects.view, name=project_name) + 
"#start_vote_template"
+    settings_url = util.as_url(projects.view, project_name=project_name) + 
"#start_vote_template"
     link = htm.div(".form-text.text-muted.mt-2")[
         "To edit the template, go to the ",
         htm.a(href=settings_url)["project settings"],
@@ -213,7 +221,7 @@ async def _render_page(
 
 
 def _render_subject_field(default_subject: str, project_name: str) -> 
htm.Element:
-    settings_url = util.as_url(projects.view, name=project_name) + 
"#start_vote_subject"
+    settings_url = util.as_url(projects.view, project_name=project_name) + 
"#start_vote_subject"
     return htm.div[
         htpy.input(
             type="text",
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..6e38d4e4
--- /dev/null
+++ b/atr/models/unsafe.py
@@ -0,0 +1,32 @@
+# 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.
+from typing import NewType
+
+
+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})"
+
+
+Path = NewType("Path", str)
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 2997b67c..b3007626 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -47,7 +47,7 @@ async def add_project(
             )
 
     return await session.redirect(
-        get.projects.view, name=label, success=f"Project '{display_name}' 
added successfully."
+        get.projects.view, project_name=label, success=f"Project 
'{display_name}' added successfully."
     )
 
 
@@ -158,10 +158,10 @@ async def _process_add_category(
 
     if modified:
         return await session.redirect(
-            get.projects.view, name=project_name, success=f"Category 
'{category_to_add}' added."
+            get.projects.view, project_name=project_name, success=f"Category 
'{category_to_add}' added."
         )
     return await session.redirect(
-        get.projects.view, name=project_name, error=f"Category 
'{category_to_add}' already exists."
+        get.projects.view, project_name=project_name, error=f"Category 
'{category_to_add}' already exists."
     )
 
 
@@ -181,10 +181,10 @@ async def _process_add_language(
 
     if modified:
         return await session.redirect(
-            get.projects.view, name=project_name, success=f"Language 
'{language_to_add}' added."
+            get.projects.view, project_name=project_name, success=f"Language 
'{language_to_add}' added."
         )
     return await session.redirect(
-        get.projects.view, name=project_name, error=f"Language 
'{language_to_add}' already exists."
+        get.projects.view, project_name=project_name, error=f"Language 
'{language_to_add}' already exists."
     )
 
 
@@ -199,10 +199,12 @@ async def _process_compose_form(
             await wacm.policy.edit_compose(compose_form)
         except storage.AccessError as e:
             return await session.redirect(
-                get.projects.view, name=project_name, error=f"Error editing 
compose policy: {e}"
+                get.projects.view, project_name=project_name, error=f"Error 
editing compose policy: {e}"
             )
 
-    return await session.redirect(get.projects.view, name=project_name, 
success="Compose options saved successfully.")
+    return await session.redirect(
+        get.projects.view, project_name=project_name, success="Compose options 
saved successfully."
+    )
 
 
 async def _process_delete_project(
@@ -231,10 +233,12 @@ async def _process_finish_form(
             await wacm.policy.edit_finish(finish_form)
         except storage.AccessError as e:
             return await session.redirect(
-                get.projects.view, name=project_name, error=f"Error editing 
finish policy: {e}"
+                get.projects.view, project_name=project_name, error=f"Error 
editing finish policy: {e}"
             )
 
-    return await session.redirect(get.projects.view, name=project_name, 
success="Finish options saved successfully.")
+    return await session.redirect(
+        get.projects.view, project_name=project_name, success="Finish options 
saved successfully."
+    )
 
 
 async def _process_remove_category(
@@ -253,10 +257,10 @@ async def _process_remove_category(
 
     if modified:
         return await session.redirect(
-            get.projects.view, name=project_name, success=f"Category 
'{category_to_remove}' removed."
+            get.projects.view, project_name=project_name, success=f"Category 
'{category_to_remove}' removed."
         )
     return await session.redirect(
-        get.projects.view, name=project_name, error=f"Category 
'{category_to_remove}' does not exist."
+        get.projects.view, project_name=project_name, error=f"Category 
'{category_to_remove}' does not exist."
     )
 
 
@@ -276,10 +280,10 @@ async def _process_remove_language(
 
     if modified:
         return await session.redirect(
-            get.projects.view, name=project_name, success=f"Language 
'{language_to_remove}' removed."
+            get.projects.view, project_name=project_name, success=f"Language 
'{language_to_remove}' removed."
         )
     return await session.redirect(
-        get.projects.view, name=project_name, error=f"Language 
'{language_to_remove}' does not exist."
+        get.projects.view, project_name=project_name, error=f"Language 
'{language_to_remove}' does not exist."
     )
 
 
@@ -291,6 +295,10 @@ async def _process_vote_form(session: web.Committer, 
vote_form: shared.projects.
         try:
             await wacm.policy.edit_vote(vote_form)
         except storage.AccessError as e:
-            return await session.redirect(get.projects.view, 
name=project_name, error=f"Error editing vote policy: {e}")
+            return await session.redirect(
+                get.projects.view, project_name=project_name, error=f"Error 
editing vote policy: {e}"
+            )
 
-    return await session.redirect(get.projects.view, name=project_name, 
success="Vote options saved successfully.")
+    return await session.redirect(
+        get.projects.view, project_name=project_name, success="Vote options 
saved successfully."
+    )
diff --git a/atr/post/sbom.py b/atr/post/sbom.py
index 09c779e3..564f0537 100644
--- a/atr/post/sbom.py
+++ b/atr/post/sbom.py
@@ -85,16 +85,16 @@ async def _augment(
         await quart.flash(f"Error augmenting SBOM: {e!s}", "error")
         return await session.redirect(
             get.sbom.report,
-            project=project_name,
-            version=version_name,
+            project_name=project_name,
+            version_name=version_name,
             file_path=str(rel_path),
         )
 
     return await session.redirect(
         get.sbom.report,
         success=f"SBOM augmentation task queued for {rel_path.name} (task ID: 
{util.unwrap(sbom_task.id)})",
-        project=project_name,
-        version=version_name,
+        project_name=project_name,
+        version_name=version_name,
         file_path=str(rel_path),
     )
 
@@ -128,15 +128,15 @@ async def _scan(
         await quart.flash(f"Error starting OSV scan: {e!s}", "error")
         return await session.redirect(
             get.sbom.report,
-            project=project_name,
-            version=version_name,
+            project_name=project_name,
+            version_name=version_name,
             file_path=str(rel_path),
         )
 
     return await session.redirect(
         get.sbom.report,
         success=f"OSV vulnerability scan queued for {rel_path.name} (task ID: 
{util.unwrap(sbom_task.id)})",
-        project=project_name,
-        version=version_name,
+        project_name=project_name,
+        version_name=version_name,
         file_path=str(rel_path),
     )
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 03927a78..da736e3b 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..eb8340c7 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -140,7 +140,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",
@@ -180,11 +180,7 @@ async def check(
     )
 
 
-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: str, 
version_name: str) -> htm.Element | None:
     if (info is None) or (not info.checker_stats):
         return None
 
@@ -210,7 +206,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)
@@ -235,6 +231,10 @@ def _render_checks_summary(info: types.PathInfo | None, 
project_name: str, versi
     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
     # But we'd still need to do some of this parsing and validation
diff --git a/atr/templates/check-selected-path-table.html 
b/atr/templates/check-selected-path-table.html
index eb01eb71..d43adae6 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -70,7 +70,7 @@
                 </form>
               {% endif %}
               {% if path.suffixes[-2:] == [".cdx", ".json"] %}
-                <a href="{{ as_url(get.sbom.report, project=project_name, 
version=version_name, file_path=path) }}"
+                <a href="{{ as_url(get.sbom.report, project_name=project_name, 
version_name=version_name, file_path=path) }}"
                    class="btn btn-sm btn-outline-secondary">SBOM report</a>
               {% endif %}
               {% if has_blockers %}
diff --git a/atr/templates/check-selected-release-info.html 
b/atr/templates/check-selected-release-info.html
index 293df9ed..3c602ff6 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -7,7 +7,7 @@
       <div class="col-md-6">
         <p>
           <strong>Project:</strong>
-          <a href="{{ as_url(get.projects.view, name=release.project.name) 
}}">{{ release.project.display_name }}</a>
+          <a href="{{ as_url(get.projects.view, 
project_name=release.project.name) }}">{{ release.project.display_name }}</a>
         </p>
         <p>
           <strong>Label:</strong> {{ release.name }}
diff --git a/atr/templates/check-selected.html 
b/atr/templates/check-selected.html
index 7e027018..c41e91fe 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -130,9 +130,9 @@
     </div>
     <p>
       <a class="btn btn-primary"
-         href="{{ as_url(get.distribution.stage_automate, 
project=release.project.name, version=release.version) }}">Distribute</a>
+         href="{{ as_url(get.distribution.stage_automate, 
project_name=release.project.name, version_name=release.version) 
}}">Distribute</a>
       <a class="btn btn-secondary"
-         href="{{ as_url(get.distribution.stage_record, 
project=release.project.name, version=release.version) }}">Record a manual 
distribution</a>
+         href="{{ as_url(get.distribution.stage_record, 
project_name=release.project.name, version_name=release.version) }}">Record a 
manual distribution</a>
     </p>
     <h2 id="more-actions">More actions</h2>
     <h3 id="ignored-checks" class="mt-4">Ignored checks</h3>
diff --git a/atr/templates/committee-directory.html 
b/atr/templates/committee-directory.html
index cf9e47ef..3eedfe0c 100644
--- a/atr/templates/committee-directory.html
+++ b/atr/templates/committee-directory.html
@@ -116,11 +116,11 @@
                         {% if committee.projects %}
                             {% for project in 
committee.projects|sort(attribute="name") %}
                                 <div class="card mb-3 shadow-sm 
page-project-subcard {% if loop.index > max_initial_projects 
%}page-project-extra d-none{% endif %} {% if project.status.value.lower() != 
"active" %}page-project-inactive{% endif %}"
-                                     data-project-url="{{ 
as_url(get.projects.view, name=project.name) }}">
+                                     data-project-url="{{ 
as_url(get.projects.view, project_name=project.name) }}">
                                     <div class="card-body p-3 d-flex 
flex-column h-100">
                                         <div class="d-flex 
justify-content-between align-items-start">
                                             <p class="mb-1 me-2 fs-6">
-                                                <a href="{{ 
as_url(get.projects.view, name=project.name) }}"
+                                                <a href="{{ 
as_url(get.projects.view, project_name=project.name) }}"
                                                    class="text-decoration-none 
stretched-link">{{ project.display_name }}</a>
                                             </p>
                                             <div>
diff --git a/atr/templates/committee-view.html 
b/atr/templates/committee-view.html
index c7fd3bbb..6ce1f66d 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -29,7 +29,7 @@
       <ul>
         {% for project in projects %}
           <li>
-            <a href="{{ as_url(get.projects.view, name=project.name) }}">{{ 
project.display_name }}</a>
+            <a href="{{ as_url(get.projects.view, project_name=project.name) 
}}">{{ project.display_name }}</a>
           </li>
         {% endfor %}
       </ul>
diff --git a/atr/templates/includes/topnav.html 
b/atr/templates/includes/topnav.html
index 6689dc35..081c55a1 100644
--- a/atr/templates/includes/topnav.html
+++ b/atr/templates/includes/topnav.html
@@ -42,7 +42,7 @@
                   </li>
                   <li>
                     <a class="dropdown-item fw-bold"
-                       href="{{ as_url(get.projects.view, name=project_name) 
}}">{{ project_short_display_name }}</a>
+                       href="{{ as_url(get.projects.view, 
project_name=project_name) }}">{{ project_short_display_name }}</a>
                   </li>
                   {% for release in releases %}
                     <li>
diff --git a/atr/templates/index-committer.html 
b/atr/templates/index-committer.html
index 021c98c9..ce5f428c 100644
--- a/atr/templates/index-committer.html
+++ b/atr/templates/index-committer.html
@@ -87,7 +87,7 @@
         </h2>
 
         <p class="mb-3">
-          <a href="{{ as_url(get.projects.view, name=project.name) }}"
+          <a href="{{ as_url(get.projects.view, project_name=project.name) }}"
              class="text-decoration-none me-2">About this project</a>
           {% if project.committee.name in current_user.committees %}
             <span class="text-muted me-2">/</span>
diff --git a/atr/templates/projects.html b/atr/templates/projects.html
index dc010218..4d30e8cf 100644
--- a/atr/templates/projects.html
+++ b/atr/templates/projects.html
@@ -37,12 +37,12 @@
     {% endif %}
     <div class="col">
       <div class="card h-100 shadow-sm position-relative page-project-card {{ 
'' if project.status.value.lower() == 'active' else 'bg-body-secondary' }}"
-           data-project-url="{{ as_url(get.projects.view, name=project.name) 
}}"
+           data-project-url="{{ as_url(get.projects.view, 
project_name=project.name) }}"
            data-is-participant="{{ 'true' if is_part else 'false' }}">
         <div class="card-body">
           <div class="row g-1">
             <div class="col-sm">
-              <h3 class="card-title fs-4 mb-3"><a href="{{ 
as_url(get.projects.view, name=project.name) }}" class="text-decoration-none 
text-body stretched-link">{{ project.display_name }}</a></h3>
+              <h3 class="card-title fs-4 mb-3"><a href="{{ 
as_url(get.projects.view, project_name=project.name) }}" 
class="text-decoration-none text-body stretched-link">{{ project.display_name 
}}</a></h3>
             </div>
             {% if project.status.value.lower() != 'active' %}
               <div class="col-sm-2">
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