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

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-releases-client.git


The following commit(s) were added to refs/heads/main by this push:
     new cb45b9d  Update models
cb45b9d is described below

commit cb45b9def0a98f8fa05339a1b78e812ba504bb5e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Apr 6 17:49:27 2026 +0100

    Update models
---
 pyproject.toml                                    |   4 +-
 src/atrclient/client.py                           |  58 +--
 src/atrclient/models/__init__.py                  |  17 +-
 src/atrclient/models/api.py                       | 140 ++++---
 src/atrclient/models/attestable.py                |  29 +-
 src/atrclient/models/distribution.py              |  36 +-
 src/atrclient/models/github.py                    |  77 ++++
 src/atrclient/models/results.py                   |  41 +-
 src/atrclient/models/safe.py                      | 324 +++++++++++++++
 src/atrclient/models/schema.py                    |   6 +-
 src/atrclient/models/sql.py                       | 478 ++++++++++++++++------
 src/atrclient/models/tabulate.py                  |  60 ++-
 src/atrclient/models/{attestable.py => unsafe.py} |  23 +-
 uv.lock                                           |   4 +-
 14 files changed, 1019 insertions(+), 278 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index acf5669..b62eeac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ build-backend = "hatchling.build"
 
 [project]
 name            = "apache-trusted-releases"
-version         = "0.20260406.1633"
+version         = "0.20260406.1643"
 description     = "ATR CLI and Python API"
 readme          = "README.md"
 requires-python = ">=3.12"
@@ -86,4 +86,4 @@ filterwarnings = [
 ]
 
 [tool.uv]
-exclude-newer = "2026-04-06T16:33:00Z"
+exclude-newer = "2026-04-06T16:43:00Z"
diff --git a/src/atrclient/client.py b/src/atrclient/client.py
index ddfaecf..be7bdfd 100755
--- a/src/atrclient/client.py
+++ b/src/atrclient/client.py
@@ -93,12 +93,12 @@ def app_announce(
     path_suffix: Annotated[str | None, cyclopts.Parameter(alias="-p", 
name="--path-suffix")] = None,
 ) -> None:
     announce_args = models.api.ReleaseAnnounceArgs(
-        project=project,
-        version=version,
-        revision=revision,
+        project=models.safe.ProjectKey(project),
+        version=models.safe.VersionKey(version),
+        revision=models.safe.RevisionNumber(revision),
         email_to=mailing_list,
         body=body or f"Release {project} {version} has been announced.",
-        path_suffix=path_suffix or "",
+        path_suffix=models.safe.RelPath(path_suffix) if path_suffix else None,
     )
     announce = api.release_announce(announce_args)
     if not announce.success:
@@ -247,7 +247,9 @@ def app_config_path() -> None:
 
 @APP_DEV.command(name="delete", help="Delete a release.")
 def app_dev_delete(project: str, version: str, /) -> None:
-    releases_delete_args = models.api.ReleaseDeleteArgs(project=project, 
version=version)
+    releases_delete_args = models.api.ReleaseDeleteArgs(
+        project=models.safe.ProjectKey(project), 
version=models.safe.VersionKey(version)
+    )
     api.release_delete(releases_delete_args)
     print(f"{project}-{version}")
 
@@ -403,7 +405,9 @@ def app_dev_user() -> None:
 
 @APP_DRAFT.command(name="delete", help="Delete a draft release.")
 def app_draft_delete(project: str, version: str, /) -> None:
-    draft_delete_args = models.api.ReleaseDraftDeleteArgs(project=project, 
version=version)
+    draft_delete_args = models.api.ReleaseDraftDeleteArgs(
+        project=models.safe.ProjectKey(project), 
version=models.safe.VersionKey(version)
+    )
     draft_delete = api.release_draft_delete(draft_delete_args)
     print(draft_delete.success)
 
@@ -425,12 +429,14 @@ def app_distribution_record(
         show.error_and_exit(f"Invalid platform: {platform}")
     platform_member = models.sql.DistributionPlatform[platform]
     distribution_record_args = models.api.DistributionRecordArgs(
-        project=project,
-        version=version,
+        project=models.safe.ProjectKey(project),
+        version=models.safe.VersionKey(version),
         platform=platform_member,
-        distribution_owner_namespace=distribution_owner_namespace,
-        distribution_package=distribution_package,
-        distribution_version=distribution_version,
+        
distribution_owner_namespace=models.safe.Alphanumeric(distribution_owner_namespace)
+        if distribution_owner_namespace
+        else None,
+        distribution_package=models.safe.Alphanumeric(distribution_package),
+        distribution_version=models.safe.VersionKey(distribution_version),
         staging=staging,
         details=details,
     )
@@ -476,9 +482,9 @@ def app_ignore_add(
     message: str | None = None,
 ) -> None:
     args = models.api.IgnoreAddArgs(
-        project_name=project,
+        project_key=models.safe.ProjectKey(project),
         release_glob=release,
-        revision_number=revision,
+        revision_number=models.safe.RevisionNumber(revision) if revision else 
None,
         checker_glob=checker,
         primary_rel_path_glob=primary_rel_path,
         member_rel_path_glob=member_rel_path,
@@ -503,7 +509,7 @@ def app_ignore_delete(
     id: int,
     /,
 ) -> None:
-    args = models.api.IgnoreDeleteArgs(project_name=project, id=id)
+    args = 
models.api.IgnoreDeleteArgs(project_key=models.safe.ProjectKey(project), id=id)
     api.ignore_delete(args)
     print("Check ignore deleted for:")
     print(f"  Project: {project}")
@@ -640,7 +646,9 @@ def app_release_list(project: str, /) -> None:
 
 @APP_RELEASE.command(name="start", help="Start a release.")
 def app_release_start(project: str, version: str, /) -> None:
-    releases_create_args = models.api.ReleaseCreateArgs(project=project, 
version=version)
+    releases_create_args = models.api.ReleaseCreateArgs(
+        project=models.safe.ProjectKey(project), 
version=models.safe.VersionKey(version)
+    )
     releases_create = api.release_create(releases_create_args)
     print(releases_create.release.model_dump_json(indent=None))
 
@@ -738,9 +746,9 @@ def app_upload(project: str, version: str, path: str, 
filepath: str, /) -> None:
         content = fh.read()
 
     upload_args = models.api.ReleaseUploadArgs(
-        project=project,
-        version=version,
-        relpath=path,
+        project=models.safe.ProjectKey(project),
+        version=models.safe.VersionKey(version),
+        relpath=models.safe.RelPath(path),
         content=base64.b64encode(content).decode("utf-8"),
     )
 
@@ -822,8 +830,8 @@ def app_vote_resolve(
     resolution: Literal["passed", "failed"],
 ) -> None:
     vote_resolve_args = models.api.VoteResolveArgs(
-        project=project,
-        version=version,
+        project=models.safe.ProjectKey(project),
+        version=models.safe.VersionKey(version),
         resolution=resolution,
     )
     api.vote_resolve(vote_resolve_args)
@@ -847,9 +855,9 @@ def app_vote_start(
             body_text = fh.read()
 
     vote_start_args = models.api.VoteStartArgs(
-        project=project,
-        version=version,
-        revision=revision,
+        project=models.safe.ProjectKey(project),
+        version=models.safe.VersionKey(version),
+        revision=models.safe.RevisionNumber(revision),
         email_to=mailing_list,
         vote_duration=duration,
         subject=subject or f"[VOTE] Release {project} {version}",
@@ -861,7 +869,9 @@ def app_vote_start(
 
 @APP_VOTE.command(name="tabulate", help="Tabulate a vote.")
 def app_vote_tabulate(project: str, version: str, /) -> None:
-    vote_tabulate_args = models.api.VoteTabulateArgs(project=project, 
version=version)
+    vote_tabulate_args = models.api.VoteTabulateArgs(
+        project=models.safe.ProjectKey(project), 
version=models.safe.VersionKey(version)
+    )
     vote_tabulate = api.vote_tabulate(vote_tabulate_args)
     print(vote_tabulate.model_dump_json(indent=2))
 
diff --git a/src/atrclient/models/__init__.py b/src/atrclient/models/__init__.py
index 3d7d9fd..20d8698 100644
--- a/src/atrclient/models/__init__.py
+++ b/src/atrclient/models/__init__.py
@@ -15,7 +15,20 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import api, basic, distribution, helpers, results, schema, session, 
sql, tabulate, validation
+from . import api, basic, distribution, github, helpers, results, safe, 
schema, session, sql, tabulate, validation
 
 # If we use .__name__, pyright gives a warning
-__all__ = ["api", "basic", "distribution", "helpers", "results", "schema", 
"session", "sql", "tabulate", "validation"]
+__all__ = [
+    "api",
+    "basic",
+    "distribution",
+    "github",
+    "helpers",
+    "results",
+    "safe",
+    "schema",
+    "session",
+    "sql",
+    "tabulate",
+    "validation",
+]
diff --git a/src/atrclient/models/api.py b/src/atrclient/models/api.py
index 9464a63..841849f 100644
--- a/src/atrclient/models/api.py
+++ b/src/atrclient/models/api.py
@@ -21,7 +21,7 @@ from typing import Annotated, Any, Literal, TypeVar
 
 import pydantic
 
-from . import schema, sql, tabulate, validation
+from . import safe, schema, sql, tabulate, validation
 
 T = TypeVar("T")
 
@@ -73,14 +73,14 @@ class DistributeSshRegisterArgs(schema.Strict):
     ssh_key: str = schema.example("ssh-ed25519 
AAAAC3NzaC1lZDI1NTEgH5C9okWi0dh25AAAAIOMqqnkVzrm0SdG6UOoqKLsabl9GKJl")
     phase: str = schema.Field(strict=False, default="compose", 
json_schema_extra={"examples": ["compose", "finish"]})
     asf_uid: str = schema.example("user")
-    project_name: str = schema.example("tooling")
-    version: str = schema.example("0.0.1")
+    project_key: safe.ProjectKey = schema.example("tooling")
+    version: safe.VersionKey = schema.example("0.0.1")
 
 
 class DistributeSshRegisterResults(schema.Strict):
     endpoint: Literal["/distribute/ssh/register"] = schema.alias("endpoint")
     fingerprint: str = 
schema.example("SHA256:0123456789abcdef0123456789abcdef01234567")
-    project: str = schema.example("example")
+    project: safe.ProjectKey = schema.example("example")
     expires: int = schema.example(1713547200)
 
 
@@ -89,7 +89,7 @@ class DistributeStatusUpdateArgs(schema.Strict):
     jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
     workflow: str = schema.description("Workflow name")
     run_id: str = schema.description("Workflow run ID")
-    project_name: str = schema.description("Project name in ATR")
+    project_key: safe.ProjectKey = schema.description("Project name in ATR")
     status: str = schema.description("Workflow status")
     message: str = schema.description("Workflow message")
 
@@ -100,12 +100,12 @@ class DistributeStatusUpdateResults(schema.Strict):
 
 
 class DistributionRecordArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
     platform: sql.DistributionPlatform = 
schema.example(sql.DistributionPlatform.ARTIFACT_HUB)
-    distribution_owner_namespace: str | None = schema.default_example(None, 
"example")
-    distribution_package: str = schema.example("example")
-    distribution_version: str = schema.example("0.0.1")
+    distribution_owner_namespace: safe.Alphanumeric | None = 
schema.default_example(None, "example")
+    distribution_package: safe.Alphanumeric = schema.example("example")
+    distribution_version: safe.VersionKey = schema.example("0.0.1")
     staging: bool = schema.example(False)
     details: bool = schema.example(False)
 
@@ -128,12 +128,12 @@ class DistributionRecordFromWorkflowArgs(schema.Strict):
     asf_uid: str = schema.example("user")
     publisher: str = schema.example("user")
     jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
     platform: sql.DistributionPlatform = 
schema.example(sql.DistributionPlatform.ARTIFACT_HUB)
-    distribution_owner_namespace: str | None = schema.default_example(None, 
"example")
-    distribution_package: str = schema.example("example")
-    distribution_version: str = schema.example("0.0.1")
+    distribution_owner_namespace: safe.Alphanumeric | None = 
schema.default_example(None, "example")
+    distribution_package: safe.Alphanumeric = schema.example("example")
+    distribution_version: safe.VersionKey = schema.example("0.0.1")
     phase: str = schema.Field(strict=False, default="compose", 
json_schema_extra={"examples": ["compose", "finish"]})
     staging: bool = schema.example(False)
     details: bool = schema.example(False)
@@ -164,9 +164,9 @@ class DistributionRecordResults(schema.Strict):
 
 
 class IgnoreAddArgs(schema.Strict):
-    project_name: str = schema.example("example")
+    project_key: safe.ProjectKey = schema.example("example")
     release_glob: str | None = schema.default_example(None, "example-0.0.*")
-    revision_number: str | None = schema.default_example(None, "00001")
+    revision_number: safe.RevisionNumber | None = schema.default_example(None, 
"00001")
     checker_glob: str | None = schema.default_example(None, 
"atr.tasks.checks.license.files")
     primary_rel_path_glob: str | None = schema.default_example(None, 
"apache-example-0.0.1-*.tar.gz")
     member_rel_path_glob: str | None = schema.default_example(None, 
"apache-example-0.0.1/*.xml")
@@ -194,7 +194,7 @@ class IgnoreAddResults(schema.Strict):
 
 
 class IgnoreDeleteArgs(schema.Strict):
-    project_name: str = schema.example("example")
+    project_key: safe.ProjectKey = schema.example("example")
     id: int = schema.example(1)
 
 
@@ -287,9 +287,9 @@ class ProjectGetResults(schema.Strict):
     project: sql.Project
 
 
-class ProjectPolicyResults(schema.Strict):
-    endpoint: Literal["/project/policy"] = schema.alias("endpoint")
-    project_name: str
+class PolicyGetResults(schema.Strict):
+    endpoint: Literal["/policy/get"] = schema.alias("endpoint")
+    project_key: safe.ProjectKey
     policy_announce_release_subject: str
     policy_announce_release_template: str
     policy_binary_artifact_paths: list[str]
@@ -302,16 +302,44 @@ class ProjectPolicyResults(schema.Strict):
     policy_mailto_addresses: list[str]
     policy_manual_vote: bool
     policy_min_hours: int
-    policy_pause_for_rm: bool
     policy_preserve_download_files: bool
     policy_release_checklist: str
     policy_source_artifact_paths: list[str]
     policy_start_vote_subject: str
     policy_start_vote_template: str
-    policy_strict_checking: bool
     policy_vote_comment_template: str
 
 
+class PolicyUpdateArgs(schema.Strict):
+    project: safe.ProjectKey = schema.example("example")
+    announce_release_subject: str | None = None
+    announce_release_template: str | None = None
+    binary_artifact_paths: list[str] | None = None
+    file_tag_mappings: dict[str, list[str]] | None = None
+    github_compose_workflow_path: list[str] | None = None
+    github_finish_workflow_path: list[str] | None = None
+    github_repository_branch: str | None = None
+    github_repository_name: str | None = None
+    github_vote_workflow_path: list[str] | None = None
+    license_check_mode: sql.LicenseCheckMode | None = None
+    mailto_addresses: list[str] | None = None
+    manual_vote: bool | None = None
+    min_hours: int | None = None
+    preserve_download_files: bool | None = None
+    release_checklist: str | None = None
+    source_artifact_paths: list[str] | None = None
+    source_excludes_lightweight: list[str] | None = None
+    source_excludes_rat: list[str] | None = None
+    start_vote_subject: str | None = None
+    start_vote_template: str | None = None
+    vote_comment_template: str | None = None
+
+
+class PolicyUpdateResults(schema.Strict):
+    endpoint: Literal["/policy/update"] = schema.alias("endpoint")
+    success: Literal[True] = schema.example(True)
+
+
 class ProjectReleasesResults(schema.Strict):
     endpoint: Literal["/project/releases"] = schema.alias("endpoint")
     releases: Sequence[sql.Release]
@@ -325,11 +353,11 @@ class ProjectsListResults(schema.Strict):
 class PublisherDistributionRecordArgs(schema.Strict):
     publisher: str = schema.example("user")
     jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
-    version: str = schema.example("0.0.1")
+    version: safe.VersionKey = schema.example("0.0.1")
     platform: sql.DistributionPlatform = 
schema.example(sql.DistributionPlatform.ARTIFACT_HUB)
-    distribution_owner_namespace: str | None = schema.default_example(None, 
"example")
-    distribution_package: str = schema.example("example")
-    distribution_version: str = schema.example("0.0.1")
+    distribution_owner_namespace: safe.Alphanumeric | None = 
schema.default_example(None, "example")
+    distribution_package: safe.Alphanumeric = schema.example("example")
+    distribution_version: safe.VersionKey = schema.example("0.0.1")
     staging: bool = schema.example(False)
     details: bool = schema.example(False)
 
@@ -356,11 +384,11 @@ class PublisherDistributionRecordResults(schema.Strict):
 class PublisherReleaseAnnounceArgs(schema.Strict):
     publisher: str = schema.example("user")
     jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
-    version: str = schema.example("0.0.1")
-    revision: str = schema.example("00005")
+    version: safe.VersionKey = schema.example("0.0.1")
+    revision: safe.RevisionNumber = schema.example("00005")
     email_to: str = schema.example("[email protected]")
     body: str = schema.example("The Apache Example team is pleased to announce 
the release of Example 1.0.0...")
-    path_suffix: str = schema.example("example/1.0.0")
+    path_suffix: safe.OptionalRelPath = schema.example("example/1.0.0")
 
 
 class PublisherReleaseAnnounceResults(schema.Strict):
@@ -377,15 +405,15 @@ class PublisherSshRegisterArgs(schema.Strict):
 class PublisherSshRegisterResults(schema.Strict):
     endpoint: Literal["/publisher/ssh/register"] = schema.alias("endpoint")
     fingerprint: str = 
schema.example("SHA256:0123456789abcdef0123456789abcdef01234567")
-    project: str = schema.example("example")
+    project: safe.ProjectKey = schema.example("example")
     expires: int = schema.example(1713547200)
 
 
 class PublisherVoteResolveArgs(schema.Strict):
     publisher: str = schema.example("user")
     jwt: str = schema.example("eyJhbGciOiJIUzI1[...]mMjLiuyu5CSpyHI=")
-    version: str = schema.example("0.0.1")
-    resolution: Literal["passed", "failed"] = schema.example("passed")
+    version: safe.VersionKey = schema.example("0.0.1")
+    resolution: Literal["passed", "failed", "cancelled"] = 
schema.example("passed")
 
 
 class PublisherVoteResolveResults(schema.Strict):
@@ -394,12 +422,12 @@ class PublisherVoteResolveResults(schema.Strict):
 
 
 class ReleaseAnnounceArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("1.0.0")
-    revision: str = schema.example("00005")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("1.0.0")
+    revision: safe.RevisionNumber = schema.example("00005")
     email_to: str = schema.example("[email protected]")
     body: str = schema.example("The Apache Example team is pleased to announce 
the release of Example 1.0.0...")
-    path_suffix: str = schema.example("example/1.0.0")
+    path_suffix: safe.OptionalRelPath = schema.example("example/1.0.0")
 
 
 class ReleaseAnnounceResults(schema.Strict):
@@ -408,8 +436,8 @@ class ReleaseAnnounceResults(schema.Strict):
 
 
 class ReleaseDraftDeleteArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
 
 
 class ReleaseDraftDeleteResults(schema.Strict):
@@ -418,8 +446,8 @@ class ReleaseDraftDeleteResults(schema.Strict):
 
 
 class ReleaseCreateArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
 
 
 class ReleaseCreateResults(schema.Strict):
@@ -428,8 +456,8 @@ class ReleaseCreateResults(schema.Strict):
 
 
 class ReleaseDeleteArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
 
 
 class ReleaseDeleteResults(schema.Strict):
@@ -466,9 +494,9 @@ class ReleaseRevisionsResults(schema.Strict):
 
 
 class ReleaseUploadArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
-    relpath: str = schema.example("example/0.0.1/example-0.0.1-bin.tar.gz")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
+    relpath: safe.RelPath = 
schema.example("example/0.0.1/example-0.0.1-bin.tar.gz")
     content: str = schema.example("This is the content of the file.")
 
 
@@ -558,8 +586,8 @@ class TasksListResults(schema.Strict):
 
 class UserInfoResults(schema.Strict):
     endpoint: Literal["/user/info"] = schema.alias("endpoint")
-    participant_of: list[str] = schema.example(["committee_name_a", 
"committee_name_b"])
-    member_of: list[str] = schema.example(["committee_name_a"])
+    participant_of: list[str] = schema.example(["committee_key_a", 
"committee_key_b"])
+    member_of: list[str] = schema.example(["committee_key_a"])
 
 
 class UsersListResults(schema.Strict):
@@ -568,9 +596,9 @@ class UsersListResults(schema.Strict):
 
 
 class VoteResolveArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
-    resolution: Literal["passed", "failed"] = schema.example("passed")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
+    resolution: Literal["passed", "failed", "cancelled"] = 
schema.example("passed")
 
 
 class VoteResolveResults(schema.Strict):
@@ -579,9 +607,9 @@ class VoteResolveResults(schema.Strict):
 
 
 class VoteStartArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
-    revision: str = schema.example("00005")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
+    revision: safe.RevisionNumber = schema.example("00005")
     email_to: str = schema.example("[email protected]")
     vote_duration: int = schema.example(10)
     subject: str = schema.example("[VOTE] Apache Example 0.0.1 release")
@@ -594,8 +622,8 @@ class VoteStartResults(schema.Strict):
 
 
 class VoteTabulateArgs(schema.Strict):
-    project: str = schema.example("example")
-    version: str = schema.example("0.0.1")
+    project: safe.ProjectKey = schema.example("example")
+    version: safe.VersionKey = schema.example("0.0.1")
 
 
 class VoteTabulateResults(schema.Strict):
diff --git a/src/atrclient/models/attestable.py 
b/src/atrclient/models/attestable.py
index 3cff655..510a105 100644
--- a/src/atrclient/models/attestable.py
+++ b/src/atrclient/models/attestable.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Annotated, Literal
+from typing import Annotated, Any, Literal
 
 import pydantic
 
@@ -25,9 +25,36 @@ from . import schema
 class HashEntry(schema.Strict):
     size: int
     uploaders: list[Annotated[tuple[str, str], 
pydantic.BeforeValidator(tuple)]]
+    basenames: list[str] = schema.factory(list)
+
+
+class AttestableChecksV1(schema.Strict):
+    version: Literal[1] = 1
+    checks: list[int] = schema.factory(list)
+
+
+class AttestableChecksV2(schema.Strict):
+    version: Literal[2] = 2
+    checks: dict[str, dict[str, str]] = schema.factory(dict)
 
 
 class AttestableV1(schema.Strict):
     version: Literal[1] = 1
     paths: dict[str, str] = schema.factory(dict)
     hashes: dict[str, HashEntry] = schema.factory(dict)
+    policy: dict[str, Any] = schema.factory(dict)
+
+
+class PathEntryV2(schema.Strict):
+    content_hash: str
+    classification: str
+
+
+class AttestableV2(schema.Strict):
+    version: Literal[2] = 2
+    hashes: dict[str, HashEntry] = schema.factory(dict)
+    paths: dict[str, PathEntryV2] = schema.factory(dict)
+    policy: dict[str, Any] = schema.factory(dict)
+
+
+type Attestable = AttestableV1 | AttestableV2
diff --git a/src/atrclient/models/distribution.py 
b/src/atrclient/models/distribution.py
index 802ddde..c805a89 100644
--- a/src/atrclient/models/distribution.py
+++ b/src/atrclient/models/distribution.py
@@ -19,23 +19,23 @@ import datetime
 
 import pydantic
 
-from . import basic, schema, sql
+from . import basic, safe, schema, sql
 
 
-class ArtifactHubAvailableVersion(schema.Lax):
+class ArtifactHubAvailableVersion(schema.Subset):
     ts: int
 
 
-class ArtifactHubLink(schema.Lax):
+class ArtifactHubLink(schema.Subset):
     url: str | None = None
     name: str | None = None
 
 
-class ArtifactHubRepository(schema.Lax):
+class ArtifactHubRepository(schema.Subset):
     name: str | None = None
 
 
-class ArtifactHubResponse(schema.Lax):
+class ArtifactHubResponse(schema.Subset):
     available_versions: list[ArtifactHubAvailableVersion] = 
pydantic.Field(default_factory=list)
     home_url: str | None = None
     links: list[ArtifactHubLink] = pydantic.Field(default_factory=list)
@@ -44,45 +44,45 @@ class ArtifactHubResponse(schema.Lax):
     repository: ArtifactHubRepository | None = None
 
 
-class DockerResponse(schema.Lax):
+class DockerResponse(schema.Subset):
     tag_last_pushed: str | None = None
 
 
-class GitHubResponse(schema.Lax):
+class GitHubResponse(schema.Subset):
     published_at: str | None = None
     html_url: str | None = None
 
 
-class MavenDoc(schema.Lax):
+class MavenDoc(schema.Subset):
     timestamp: int | None = None
 
 
-class MavenResponseBody(schema.Lax):
+class MavenResponseBody(schema.Subset):
     start: int | None = None
     docs: list[MavenDoc] = pydantic.Field(default_factory=list)
 
 
-class MavenResponse(schema.Lax):
+class MavenResponse(schema.Subset):
     response: MavenResponseBody = 
pydantic.Field(default_factory=MavenResponseBody)
 
 
-class NpmResponse(schema.Lax):
+class NpmResponse(schema.Subset):
     name: str | None = None
     time: dict[str, str] = pydantic.Field(default_factory=dict)
     homepage: str | None = None
 
 
-class PyPIUrl(schema.Lax):
+class PyPIUrl(schema.Subset):
     upload_time_iso_8601: str | None = None
     url: str | None = None
 
 
-class PyPIInfo(schema.Lax):
+class PyPIInfo(schema.Subset):
     release_url: str | None = None
     project_url: str | None = None
 
 
-class PyPIResponse(schema.Lax):
+class PyPIResponse(schema.Subset):
     urls: list[PyPIUrl] = pydantic.Field(default_factory=list)
     info: PyPIInfo = pydantic.Field(default_factory=PyPIInfo)
 
@@ -91,11 +91,11 @@ class PyPIResponse(schema.Lax):
 # Our previous forms implementation typed platform as Any, which was 
insufficient
 # And this way we also get nice JSON from the Pydantic model dump
 # Including all of the enum properties
-class Data(schema.Lax):
+class Data(schema.Subset):
     platform: sql.DistributionPlatform
-    owner_namespace: str | None = None
-    package: str
-    version: str
+    owner_namespace: safe.Alphanumeric | None = None
+    package: safe.Alphanumeric
+    version: safe.VersionKey
     details: bool
 
     @pydantic.field_validator("owner_namespace", mode="before")
diff --git a/src/atrclient/models/github.py b/src/atrclient/models/github.py
new file mode 100644
index 0000000..4a4d67e
--- /dev/null
+++ b/src/atrclient/models/github.py
@@ -0,0 +1,77 @@
+# 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 __future__ import annotations
+
+import time
+
+import pydantic
+
+from . import schema
+
+
+class TrustedPublisherPayload(schema.Subset):
+    actor: str
+    actor_id: int
+    aud: str
+    base_ref: str
+    check_run_id: str
+    enterprise: str
+    enterprise_id: str
+    event_name: str
+    exp: int | None = None
+    head_ref: str
+    iat: int
+    iss: str
+    job_workflow_ref: str
+    job_workflow_sha: str
+    jti: str
+    nbf: int | None = None
+    ref: str
+    ref_protected: str
+    ref_type: str
+    repository: str
+    repository_owner: str
+    repository_visibility: str
+    run_attempt: str
+    run_number: str
+    runner_environment: str
+    sha: str
+    sub: str
+    workflow: str
+    workflow_ref: str
+    workflow_sha: str
+
+    @pydantic.field_validator("exp")
+    @classmethod
+    def _validate_exp(cls, value: int | None) -> int | None:
+        if value is None:
+            return value
+        now = int(time.time())
+        if now > value:
+            raise ValueError("Token has expired")
+        return value
+
+    @pydantic.field_validator("nbf")
+    @classmethod
+    def _validate_nbf(cls, value: int | None) -> int | None:
+        if value is None:
+            return value
+        now = int(time.time())
+        if value and (now < value):
+            raise ValueError("Token not yet valid")
+        return value
diff --git a/src/atrclient/models/results.py b/src/atrclient/models/results.py
index eeb73b9..cd04e22 100644
--- a/src/atrclient/models/results.py
+++ b/src/atrclient/models/results.py
@@ -19,7 +19,7 @@ from typing import Annotated, Any, Literal
 
 import pydantic
 
-from . import schema
+from . import safe, schema
 
 
 class DistributionStatusCheck(schema.Strict):
@@ -105,14 +105,14 @@ class OSVComponent(schema.Strict):
 
 class SBOMOSVScan(schema.Strict):
     kind: Literal["sbom_osv_scan"] = schema.Field(alias="kind")
-    project_name: str = schema.description("Project name")
-    version_name: str = schema.description("Version name")
-    revision_number: str = schema.description("Revision number")
+    project_key: safe.ProjectKey = schema.description("Project name")
+    version_key: safe.VersionKey = schema.description("Version name")
+    revision_number: safe.RevisionNumber = schema.description("Revision 
number")
     bom_version: int | None = schema.Field(
         default=None, strict=False, description="BOM Version produced with 
scan results"
     )
-    file_path: str = schema.description("Relative path to the scanned SBOM 
file")
-    new_file_path: str = schema.Field(default="", strict=False, 
description="Relative path to the updated SBOM file")
+    file_path: str = schema.description("Absolute path to the scanned SBOM 
file")
+    new_file_path: str = schema.Field(default="", strict=False, 
description="Absolute path to the updated SBOM file")
     components: list[OSVComponent] = schema.description("Components with 
vulnerabilities")
     ignored: list[str] = schema.description("Components ignored")
 
@@ -163,25 +163,35 @@ class SBOMAugment(schema.Strict):
     )
 
 
+class SBOMConvert(schema.Strict):
+    kind: Literal["sbom_convert"] = schema.Field(alias="kind")
+    path: str = schema.description("The path to the converted SBOM file")
+    bom_version: int | None = schema.Field(
+        default=None,
+        strict=False,
+        description="BOM Version produced by the convert task",
+    )
+
+
 class SBOMQsScore(schema.Strict):
     kind: Literal["sbom_qs_score"] = schema.Field(alias="kind")
-    project_name: str = schema.description("Project name")
-    version_name: str = schema.description("Version name")
-    revision_number: str = schema.description("Revision number")
-    file_path: str = schema.description("Relative path to the scored SBOM 
file")
+    project_key: safe.ProjectKey = schema.description("Project name")
+    version_key: safe.VersionKey = schema.description("Version name")
+    revision_number: safe.RevisionNumber = schema.description("Revision 
number")
+    file_path: safe.RelPath = schema.description("Relative path to the scored 
SBOM file")
     report: SbomQsReport
 
 
 class SBOMToolScore(schema.Strict):
     kind: Literal["sbom_tool_score"] = schema.Field(alias="kind")
-    project_name: str = schema.description("Project name")
-    version_name: str = schema.description("Version name")
-    revision_number: str = schema.description("Revision number")
+    project_key: safe.ProjectKey = schema.description("Project name")
+    version_key: safe.VersionKey = schema.description("Version name")
+    revision_number: safe.RevisionNumber = schema.description("Revision 
number")
     bom_version: int | None = schema.Field(default=None, strict=False, 
description="BOM Version scanned")
     prev_bom_version: int | None = schema.Field(
         default=None, strict=False, description="BOM Version from previous 
release"
     )
-    file_path: str = schema.description("Relative path to the scored SBOM 
file")
+    file_path: safe.RelPath = schema.description("Relative path to the scored 
SBOM file")
     warnings: list[str] = schema.description("Warnings from the SBOM tool")
     errors: list[str] = schema.description("Errors from the SBOM tool")
     outdated: list[str] | str | None = schema.description("Outdated tool(s) 
from the SBOM tool")
@@ -218,7 +228,7 @@ class VoteInitiate(schema.Strict):
 
     kind: Literal["vote_initiate"] = schema.Field(alias="kind")
     message: str = schema.description("The message from the vote initiation")
-    email_to: str = schema.description("The email address the vote was sent 
to")
+    email_to: str = schema.description("The email To address the vote was sent 
to")
     vote_end: str = schema.description("The date and time the vote ends")
     subject: str = schema.description("The subject of the vote email")
     mid: str | None = schema.description("The message ID of the vote email")
@@ -241,6 +251,7 @@ Results = Annotated[
     | MessageSend
     | MetadataUpdate
     | SBOMAugment
+    | SBOMConvert
     | SBOMGenerateCycloneDX
     | SBOMOSVScan
     | SBOMQsScore
diff --git a/src/atrclient/models/safe.py b/src/atrclient/models/safe.py
new file mode 100644
index 0000000..3e86f3f
--- /dev/null
+++ b/src/atrclient/models/safe.py
@@ -0,0 +1,324 @@
+# 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 __future__ import annotations
+
+import pathlib
+import string
+import unicodedata
+from typing import Annotated, Any, Final
+
+import pydantic
+
+_ALPHANUM: Final = frozenset(string.ascii_letters + string.digits + "-")
+_NUMERIC: Final = frozenset(string.digits)
+_PATH_CHARS: Final = frozenset(string.ascii_letters + string.digits + 
"-._+~/()")
+_VERSION_CHARS: Final = _ALPHANUM | frozenset(".+")
+
+
+class SafeType:
+    __slots__ = ("_value",)
+
+    @classmethod
+    def _valid_chars(cls) -> frozenset[str]:
+        # default is the base set; subclasses can override this method
+        return frozenset()
+
+    def _additional_validations(self, value: str):
+        pass
+
+    def __init__(self, value: str) -> None:
+        if not value:
+            raise ValueError("Value cannot be empty")
+
+        _assert_standard_safe_syntax(value)
+
+        if not all(c in self._valid_chars() for c in value):
+            raise ValueError("Value contains invalid characters")
+
+        self._additional_validations(value)
+
+        self._value = value
+
+    def __bool__(self) -> bool:
+        return True
+
+    def __fspath__(self) -> str:
+        return self._value
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, self.__class__):
+            return self._value == other._value
+        return NotImplemented
+
+    def __hash__(self) -> int:
+        return hash(self._value)
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self._value!r})"
+
+    def __str__(self) -> str:
+        return self._value
+
+    @classmethod
+    def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> 
Any:
+        import pydantic_core.core_schema as core_schema
+
+        return core_schema.no_info_plain_validator_function(
+            lambda v: cls(v) if isinstance(v, str) else v,
+            serialization=core_schema.to_string_ser_schema(),
+        )
+
+    @classmethod
+    def __get_pydantic_json_schema__(cls, _core_schema: Any, _handler: Any) -> 
dict[str, Any]:
+        return {"type": "string"}
+
+
+class StatePath:
+    """An absolute path within the managed storage system.
+
+    Tracks the managed root directory and ensures all derived paths remain 
within it.
+    The initial path (from get_*_dir) is the root; paths created via / carry 
it forward.
+    """
+
+    __slots__ = ("_path", "_root")
+
+    def __init__(self, path: pathlib.Path, root: pathlib.Path | None = None) 
-> None:
+        if not path.is_absolute():
+            raise ValueError("Path must be absolute")
+        resolved = path.resolve()
+        managed_root = (root or path).resolve()
+        if not resolved.is_relative_to(managed_root):
+            raise ValueError(f"Path {resolved} is not within managed root 
{managed_root}")
+        self._path = path
+        self._root = root or path
+
+    def __fspath__(self) -> str:
+        return str(self._path)
+
+    def __str__(self) -> str:
+        return str(self._path)
+
+    def __truediv__(self, other: str | pathlib.Path | SafeType) -> StatePath:
+        validated = other if isinstance(other, SafeType) else 
RelPath(str(other))
+        return StatePath(self._path / validated, self._root)
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, self.__class__):
+            return self._path == other._path
+        return NotImplemented
+
+    @property
+    def parent(self) -> StatePath:
+        """Root-safe parent - cannot traverse outside the original root path"""
+        return StatePath(self._path.parent, self._root)
+
+    @property
+    def path(self) -> pathlib.Path:
+        return self._path
+
+    @property
+    def root(self) -> pathlib.Path:
+        return self._root
+
+    @property
+    def name(self) -> str:
+        return self._path.name
+
+    @classmethod
+    def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> 
Any:
+        import pydantic_core.core_schema as core_schema
+
+        def _validate(v: Any) -> Any:
+            if isinstance(v, str):
+                return cls(pathlib.Path(v))
+            if isinstance(v, dict) and v.get("__type__") == "StatePath":
+                return cls(pathlib.Path(v["path"]), pathlib.Path(v["root"]))
+            return v
+
+        def _serialize(v: Any) -> Any:
+            return {"__type__": "StatePath", "path": str(v.path), "root": 
str(v.root)}
+
+        return core_schema.no_info_plain_validator_function(
+            _validate,
+            
serialization=core_schema.plain_serializer_function_ser_schema(_serialize),
+        )
+
+    @classmethod
+    def __get_pydantic_json_schema__(cls, _core_schema: Any, _handler: Any) -> 
dict[str, Any]:
+        return {
+            "type": "object",
+            "properties": {
+                "__type__": {"type": "string", "const": "StatePath"},
+                "path": {"type": "string", "format": "path"},
+                "root": {"type": "string", "format": "path"},
+            },
+            "required": ["__type__", "path", "root"],
+        }
+
+
+class Alphanumeric(SafeType):
+    @classmethod
+    def _valid_chars(cls) -> frozenset[str]:
+        # default is the base set; subclasses can override this method
+        return _ALPHANUM
+
+
+class CommitteeKey(Alphanumeric):
+    pass
+
+
+class Numeric(SafeType):
+    @classmethod
+    def _valid_chars(cls) -> frozenset[str]:
+        # default is the base set; subclasses can override this method
+        return _NUMERIC
+
+
+class ProjectKey(Alphanumeric):
+    """A project name that has been validated for safety."""
+
+
+class ReleaseKey(Alphanumeric):
+    """A release name composed from a validated ProjectKey and VersionKey."""
+
+    @classmethod
+    def _valid_chars(cls) -> frozenset[str]:
+        return _VERSION_CHARS
+
+
+class RelPath(SafeType):
+    """A relative file path that has been validated for safety."""
+
+    @classmethod
+    def _valid_chars(cls) -> frozenset[str]:
+        return _PATH_CHARS
+
+    def _additional_validations(self, value: str) -> None:
+        posix_path = pathlib.PurePosixPath(value)
+        windows_path = pathlib.PureWindowsPath(value)
+        if posix_path.is_absolute() or windows_path.is_absolute():
+            raise ValueError("Absolute paths are not allowed")
+        if "//" in value:
+            raise ValueError("Path cannot contain empty segments")
+        for segment in pathlib.Path(value).parts:
+            if segment in (".", ".."):
+                raise ValueError("Path cannot contain directory traversal")
+            if segment in (".git", ".svn"):
+                raise ValueError("Path cannot contain SCM directories")
+            if segment.startswith(".") and not segment.startswith(".atr") and 
segment != ".gitkeep":
+                raise ValueError("Path cannot contain dotfiles")
+
+    def as_path(self) -> pathlib.Path:
+        """Return the validated path as a pathlib.Path."""
+        return pathlib.Path(self._value)
+
+    @classmethod
+    def from_path(cls, value: pathlib.Path) -> RelPath:
+        return cls(str(value))
+
+    def append(self, path: str | pathlib.Path) -> RelPath:
+        return RelPath(f"{self!s}/{path!s}")
+
+    def prepend(self, path: str | pathlib.Path) -> RelPath:
+        return RelPath(f"{path!s}/{self!s}")
+
+    def removeprefix(self, prefix: str):
+        return RelPath(self._value.removeprefix(prefix))
+
+    def __lt__(self, other):
+        if not isinstance(other, RelPath):
+            return NotImplemented
+        return self.as_path() < other.as_path()
+
+    def __le__(self, other):
+        if not isinstance(other, RelPath):
+            return NotImplemented
+        return self.as_path() <= other.as_path()
+
+    def __gt__(self, other):
+        if not isinstance(other, RelPath):
+            return NotImplemented
+        return self.as_path() > other.as_path()
+
+    def __ge__(self, other):
+        if not isinstance(other, RelPath):
+            return NotImplemented
+        return self.as_path() >= other.as_path()
+
+
+class RevisionNumber(Numeric):
+    """A revision number that has been validated for safety."""
+
+
+class VersionKey(Alphanumeric):
+    """A version name that has been validated for safety"""
+
+    @classmethod
+    def _valid_chars(cls) -> frozenset[str]:
+        return _VERSION_CHARS
+
+    def _additional_validations(self, value: str):
+        if value[0] not in _ALPHANUM:
+            raise ValueError("A version should start with an alphanumeric 
character")
+        if value[-1] not in _ALPHANUM:
+            raise ValueError("A version should end with an alphanumeric 
character")
+
+
+def _empty_to_none(v: object) -> object:
+    if isinstance(v, str) and (not v):
+        return None
+    return v
+
+
+def _strip_slashes_or_none(v: object) -> object:
+    """Strip leading/trailing slashes from a path string; return None if only 
slashes."""
+    if isinstance(v, str):
+        stripped = v.strip("/")
+        if not stripped:
+            return None
+        return stripped
+    return v
+
+
+type OptionalAlphanumeric = Annotated[
+    Alphanumeric | None,
+    pydantic.BeforeValidator(_strip_slashes_or_none),
+]
+
+type OptionalRelPath = Annotated[
+    RelPath | None,
+    pydantic.BeforeValidator(_strip_slashes_or_none),
+]
+
+type OptionalRevisionNumber = Annotated[
+    RevisionNumber | None,
+    pydantic.BeforeValidator(_empty_to_none),
+]
+
+
+def _assert_standard_safe_syntax(value: str) -> None:
+    if unicodedata.normalize("NFC", value) != value:
+        raise ValueError("Value must be NFC-normalized")
+
+    for c in value:
+        cat = unicodedata.category(c)
+        if cat[0] == "C":
+            raise ValueError("Value contains disallowed control/format 
character")
+
+        if cat[0] == "M":
+            raise ValueError("Value contains disallowed combining mark")
diff --git a/src/atrclient/models/schema.py b/src/atrclient/models/schema.py
index f7ac483..13658ca 100644
--- a/src/atrclient/models/schema.py
+++ b/src/atrclient/models/schema.py
@@ -32,6 +32,10 @@ class Strict(pydantic.BaseModel):
     model_config = pydantic.ConfigDict(extra="forbid", strict=True, 
validate_assignment=True)
 
 
+class Subset(pydantic.BaseModel):
+    model_config = pydantic.ConfigDict(extra="ignore", strict=False, 
validate_assignment=True, validate_by_name=True)
+
+
 class Form(pydantic.BaseModel):
     model_config = pydantic.ConfigDict(
         extra="forbid",
@@ -41,7 +45,7 @@ class Form(pydantic.BaseModel):
         str_strip_whitespace=True,
     )
 
-    csrf_token: str | None = None
+    csrf_token: str
 
 
 def alias(alias_name: str) -> Any:
diff --git a/src/atrclient/models/sql.py b/src/atrclient/models/sql.py
index a6e9aed..6346743 100644
--- a/src/atrclient/models/sql.py
+++ b/src/atrclient/models/sql.py
@@ -24,7 +24,7 @@
 import dataclasses
 import datetime
 import enum
-from typing import Any, Final, Literal, Optional, TypeVar
+from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypeVar, 
overload
 
 import pydantic
 import sqlalchemy
@@ -34,7 +34,10 @@ import sqlalchemy.orm as orm
 import sqlalchemy.sql.expression as expression
 import sqlmodel
 
-from . import results, schema
+from . import results, safe, schema
+
+if TYPE_CHECKING:
+    from . import distribution
 
 T = TypeVar("T")
 
@@ -121,7 +124,7 @@ class DistributionPlatform(enum.Enum):
     # )
     MAVEN = DistributionPlatformValue(
         name="Maven Central",
-        gh_slug="maven",
+        gh_slug="mavencentral",
         
template_url="https://repo1.maven.org/maven2/{owner_namespace}/{package}/maven-metadata.xml";,
         # Below is the old template using the maven search API - but the index 
isn't updated quickly enough for us
         # 
template_url="https://search.maven.org/solrsearch/select?q=g:{owner_namespace}+AND+a:{package}+AND+v:{version}&core=gav&rows=20&wt=json";,
@@ -164,6 +167,13 @@ class ProjectStatus(enum.StrEnum):
     STANDING = "standing"
 
 
+class QuarantineStatus(enum.Enum):
+    STAGING = "STAGING"
+    PENDING = "PENDING"
+    FAILED = "FAILED"
+    ACKNOWLEDGED = "ACKNOWLEDGED"
+
+
 class ReleasePhase(enum.StrEnum):
     # TODO: Rename these to the UI names?
     # COMPOSE, VOTE, FINISH, "DISTRIBUTE"
@@ -201,8 +211,10 @@ class TaskType(enum.StrEnum):
     MESSAGE_SEND = "message_send"
     METADATA_UPDATE = "metadata_update"
     PATHS_CHECK = "paths_check"
+    QUARANTINE_VALIDATE = "quarantine_validate"
     RAT_CHECK = "rat_check"
     SBOM_AUGMENT = "sbom_augment"
+    SBOM_CONVERT = "sbom_convert"
     SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
     SBOM_OSV_SCAN = "sbom_osv_scan"
     SBOM_QS_SCORE = "sbom_qs_score"
@@ -233,6 +245,14 @@ def pydantic_example(value: Any) -> 
dict[Literal["json_schema_extra"], dict[str,
     return {"json_schema_extra": {"example": value}}
 
 
+class QuarantineFileEntryV1(schema.Strict):
+    version: Literal[1] = 1
+    rel_path: str
+    size_bytes: int
+    content_hash: str
+    errors: list[str] = schema.factory(list)
+
+
 class VoteEntry(schema.Strict):
     result: bool = schema.Field(alias="result", **pydantic_example(True))
     summary: str = schema.Field(alias="summary", **pydantic_example("This is a 
summary"))
@@ -284,6 +304,67 @@ class UTCDateTime(sqlalchemy.types.TypeDecorator):
             return value
 
 
+class SafeJSON(sqlalchemy.types.TypeDecorator):
+    """JSON column that serialises SafeType and StatePath values.
+
+    Use instead of sqlalchemy.JSON whenever the stored value may contain
+    atr.models.safe.SafeType instances (which are not JSON-serialisable by
+    the standard library encoder) or atr.models.safe.StatePath instances
+    (which include a managed root that must survive the round-trip).
+    """
+
+    impl = sqlalchemy.JSON
+    cache_ok = True
+
+    def process_bind_param(self, value, dialect):
+        if value is None:
+            return None
+        if hasattr(value, "model_dump"):
+            return value.model_dump(mode="json")
+        return _safe_json_encode(value)
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        return _safe_json_decode(value)
+
+
+def _safe_json_encode(value: Any) -> Any:
+    """Recursively convert SafeType/StatePath instances to JSON-serialisable 
form."""
+    from . import safe
+
+    if isinstance(value, safe.StatePath):
+        return {"__type__": "StatePath", "path": str(value.path), "root": 
str(value.root)}
+    if isinstance(value, safe.SafeType):
+        return str(value)
+    if isinstance(value, dict):
+        for k in value:
+            if not isinstance(k, str):
+                raise TypeError(f"Dict key must be str, got 
{type(k).__name__!r}: {k!r}")
+        return {k: _safe_json_encode(v) for k, v in value.items()}
+    if isinstance(value, list):
+        return [_safe_json_encode(v) for v in value]
+    return value
+
+
+def _safe_json_decode(value: Any) -> Any:
+    """
+    Reconstruct StatePath instances from tagged dicts produced by 
_safe_json_encode.
+    Other types are handled cleanly by Pydantic so just return the value
+    """
+    import pathlib
+
+    from . import safe
+
+    if isinstance(value, dict):
+        if value.get("__type__") == "StatePath":
+            return safe.StatePath(pathlib.Path(value["path"]), 
pathlib.Path(value["root"]))
+        return {k: _safe_json_decode(v) for k, v in value.items()}
+    if isinstance(value, list):
+        return [_safe_json_decode(v) for v in value]
+    return value
+
+
 class ResultsJSON(sqlalchemy.types.TypeDecorator):
     impl = sqlalchemy.JSON
     cache_ok = True
@@ -292,7 +373,7 @@ class ResultsJSON(sqlalchemy.types.TypeDecorator):
         if value is None:
             return None
         if hasattr(value, "model_dump"):
-            return value.model_dump()
+            return value.model_dump(mode="json")
         if isinstance(value, dict):
             return value
         raise ValueError("Unsupported value for Results column")
@@ -307,6 +388,24 @@ class ResultsJSON(sqlalchemy.types.TypeDecorator):
             return None
 
 
+_QUARANTINE_FILE_METADATA_ADAPTER: Final = 
pydantic.TypeAdapter(list[QuarantineFileEntryV1])
+
+
+class QuarantineFileMetadataJSON(sqlalchemy.types.TypeDecorator):
+    impl = sqlalchemy.JSON
+    cache_ok = True
+
+    def process_bind_param(self, value, dialect):
+        if value is None:
+            return None
+        return _QUARANTINE_FILE_METADATA_ADAPTER.dump_python(value, 
mode="json")
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        return _QUARANTINE_FILE_METADATA_ADAPTER.validate_python(value)
+
+
 # SQL models
 
 
@@ -319,7 +418,7 @@ def example(value: Any) -> dict[Literal["schema_extra"], 
dict[str, Any]]:
 
 # KeyLink:
 class KeyLink(sqlmodel.SQLModel, table=True):
-    committee_name: str = sqlmodel.Field(foreign_key="committee.name", 
primary_key=True)
+    committee_key: str = sqlmodel.Field(foreign_key="committee.key", 
primary_key=True)
     key_fingerprint: str = 
sqlmodel.Field(foreign_key="publicsigningkey.fingerprint", primary_key=True)
 
 
@@ -329,16 +428,17 @@ class PersonalAccessToken(sqlmodel.SQLModel, table=True):
     asfuid: str = sqlmodel.Field(index=True)
     token_hash: str = sqlmodel.Field(unique=True)
     created: datetime.datetime = sqlmodel.Field(
-        default_factory=lambda: datetime.datetime.now(datetime.UTC), 
sa_column=sqlalchemy.Column(UTCDateTime)
+        default_factory=lambda: datetime.datetime.now(datetime.UTC),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
     )
-    expires: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+    expires: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime, nullable=False))
     last_used: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
     label: str | None = None
 
 
 # RevisionCounter:
 class RevisionCounter(sqlmodel.SQLModel, table=True):
-    release_name: str = sqlmodel.Field(primary_key=True)
+    release_key: str = sqlmodel.Field(primary_key=True)
     last_allocated_number: int = sqlmodel.Field(default=0)
 
 
@@ -356,11 +456,17 @@ class Task(sqlmodel.SQLModel, table=True):
     id: int = sqlmodel.Field(default=None, primary_key=True)
     status: TaskStatus = sqlmodel.Field(default=TaskStatus.QUEUED, index=True)
     task_type: TaskType
-    task_args: Any = 
sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    task_args: Any = sqlmodel.Field(sa_column=sqlalchemy.Column(SafeJSON))
+    inputs_hash: str | None = sqlmodel.Field(
+        default=None,
+        **example("blake3:7f83b1657ff1fc..."),
+        unique=True,
+        index=True,
+    )
     asf_uid: str
     added: datetime.datetime = sqlmodel.Field(
         default_factory=lambda: datetime.datetime.now(datetime.UTC),
-        sa_column=sqlalchemy.Column(UTCDateTime, index=True),
+        sa_column=sqlalchemy.Column(UTCDateTime, index=True, nullable=False),
     )
     scheduled: datetime.datetime | None = sqlmodel.Field(
         default=None,
@@ -382,8 +488,8 @@ class Task(sqlmodel.SQLModel, table=True):
 
     # Used for check tasks
     # We don't put these in task_args because we want to query them efficiently
-    project_name: str | None = sqlmodel.Field(default=None, 
foreign_key="project.name")
-    version_name: str | None = sqlmodel.Field(default=None, index=True)
+    project_key: str | None = sqlmodel.Field(default=None, 
foreign_key="project.key")
+    version_key: str | None = sqlmodel.Field(default=None, index=True)
     revision_number: str | None = sqlmodel.Field(default=None, index=True)
     primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
 
@@ -403,6 +509,11 @@ class Task(sqlmodel.SQLModel, table=True):
         if isinstance(self.completed, str):
             self.completed = 
datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
 
+    @property
+    def safe_primary_rel_path(self) -> safe.RelPath | None:
+        """Get the typesafe validated relative path for the task, if set."""
+        return safe.RelPath(self.primary_rel_path) if self.primary_rel_path 
else None
+
     # Create an index on status and added for efficient task claiming
     __table_args__ = (
         sqlalchemy.Index("ix_task_status_added", "status", "added"),
@@ -440,7 +551,7 @@ class TextValue(sqlmodel.SQLModel, table=True):
 class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
     fingerprint: str = sqlmodel.Field(primary_key=True, index=True)
     key: str = sqlmodel.Field()
-    project_name: str = sqlmodel.Field(index=True)
+    project_key: str = sqlmodel.Field(index=True)
     asf_uid: str = sqlmodel.Field(index=True)
     github_uid: str = sqlmodel.Field(index=True)
     github_nid: int = sqlmodel.Field(index=True)
@@ -455,10 +566,8 @@ class WorkflowSSHKey(sqlmodel.SQLModel, table=True):
 
 # Committee: Committee Project PublicSigningKey
 class Committee(sqlmodel.SQLModel, table=True):
-    # TODO: Consider using key or label for primary string keys
-    # Then we can use simply "name" for full_name, and make it str rather than 
str | None
-    name: str = sqlmodel.Field(unique=True, primary_key=True, 
**example("example"))
-    full_name: str | None = sqlmodel.Field(default=None, **example("Example"))
+    key: str = sqlmodel.Field(unique=True, primary_key=True, 
**example("example"))
+    name: str | None = sqlmodel.Field(default=None, **example("Example"))
     # True only if this is an incubator podling with a PPMC
     is_podling: bool = sqlmodel.Field(default=False)
 
@@ -466,13 +575,13 @@ class Committee(sqlmodel.SQLModel, table=True):
     # M-1: Committee -> Committee
     child_committees: list["Committee"] = sqlmodel.Relationship(
         sa_relationship_kwargs=dict(
-            backref=orm.backref("parent_committee", 
remote_side="Committee.name"),
+            backref=orm.backref("parent_committee", 
remote_side="Committee.key"),
         ),
     )
 
     # M-1: Committee -> Committee
     # 1-M: Committee -> [Committee]
-    parent_committee_name: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.name")
+    parent_committee_key: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.key")
     # parent_committee: Optional["Committee"]
 
     # 1-M: Committee -> [Project]
@@ -480,13 +589,17 @@ class Committee(sqlmodel.SQLModel, table=True):
     projects: list["Project"] = 
sqlmodel.Relationship(back_populates="committee")
 
     committee_members: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["sbp", "tn", "wave"])
+        default_factory=list,
+        sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False),
+        **example(["sbp", "arm", "wave"]),
     )
     committers: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["sbp", "tn", "wave"])
+        default_factory=list,
+        sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False),
+        **example(["sbp", "arm", "wave"]),
     )
     release_managers: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["wave"])
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False), **example(["wave"])
     )
 
     # M-M: Committee -> [PublicSigningKey]
@@ -498,8 +611,8 @@ class Committee(sqlmodel.SQLModel, table=True):
     @property
     def display_name(self) -> str:
         """Get the display name for the committee."""
-        name = self.full_name or self.name.title()
-        return f"{name} (PPMC)" if self.is_podling else name
+        name = self.name or self.key.title()
+        return f"{name} (Incubating)" if self.is_podling else name
 
 
 def see_also(arg: Any) -> None:
@@ -508,18 +621,16 @@ def see_also(arg: Any) -> None:
 
 # Project: Project Committee Release DistributionChannel ReleasePolicy
 class Project(sqlmodel.SQLModel, table=True):
-    # TODO: Consider using key or label for primary string keys
-    # Then we can use simply "name" for full_name, and make it str rather than 
str | None
-    name: str = sqlmodel.Field(unique=True, primary_key=True, 
**example("example"))
+    key: str = sqlmodel.Field(primary_key=True, unique=True, 
**example("example"))
     # TODO: Ideally full_name would be unique for str only, but that's complex
     # We always include "Apache" in the full_name
-    full_name: str | None = sqlmodel.Field(default=None, **example("Apache 
Example"))
+    name: str | None = sqlmodel.Field(default=None, **example("Apache 
Example"))
 
     status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE, 
**example(ProjectStatus.ACTIVE))
 
     # M-1: Project -> Project
     # 1-M: (Project.child_project is missing, would be Project -> [Project])
-    super_project_name: str | None = sqlmodel.Field(default=None, 
foreign_key="project.name")
+    super_project_key: str | None = sqlmodel.Field(default=None, 
foreign_key="project.key")
     # NOTE: Neither "Project" | None nor "Project | None" works
     super_project: Optional["Project"] = sqlmodel.Relationship()
 
@@ -529,7 +640,7 @@ class Project(sqlmodel.SQLModel, table=True):
 
     # M-1: Project -> Committee
     # 1-M: Committee -> [Project]
-    committee_name: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.name", **example("example"))
+    committee_key: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.key", **example("example"))
     committee: Committee | None = 
sqlmodel.Relationship(back_populates="projects")
     see_also(Committee.projects)
 
@@ -551,7 +662,7 @@ class Project(sqlmodel.SQLModel, table=True):
 
     created: datetime.datetime = sqlmodel.Field(
         default_factory=lambda: datetime.datetime.now(datetime.UTC),
-        sa_column=sqlalchemy.Column(UTCDateTime),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
         **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
     created_by: str | None = sqlmodel.Field(default=None, **example("user"))
@@ -559,11 +670,16 @@ class Project(sqlmodel.SQLModel, table=True):
     @property
     def display_name(self) -> str:
         """Get the display name for the Project."""
-        base = self.full_name or self.name
+        base = self.name or str(self.key)
         if self.committee and self.committee.is_podling:
             return f"{base} (Incubating)"
         return base
 
+    @property
+    def safe_key(self) -> safe.ProjectKey:
+        """Get the typesafe validated name for the Project"""
+        return safe.ProjectKey(self.key)
+
     @property
     def short_display_name(self) -> str:
         """Get the short display name for the Project."""
@@ -646,10 +762,10 @@ Thanks,
     def policy_mailto_addresses(self) -> list[str]:
         if ((policy := self.release_policy) is None) or (not 
policy.mailto_addresses):
             if self.committee is not None:
-                return [f"dev@{self.committee.name}.apache.org", 
f"private@{self.committee.name}.apache.org"]
+                return [f"dev@{self.committee.key}.apache.org", 
f"private@{self.committee.key}.apache.org"]
             else:
                 # TODO: Or raise an error?
-                return [f"dev@{self.name}.apache.org", 
f"private@{self.name}.apache.org"]
+                return [f"dev@{self.key}.apache.org", 
f"private@{self.key}.apache.org"]
         return policy.mailto_addresses
 
     @property
@@ -665,12 +781,6 @@ Thanks,
             return self.policy_default_min_hours
         return policy.min_hours
 
-    @property
-    def policy_pause_for_rm(self) -> bool:
-        if (policy := self.release_policy) is None:
-            return False
-        return policy.pause_for_rm
-
     @property
     def policy_release_checklist(self) -> str:
         if ((policy := self.release_policy) is None) or 
(policy.release_checklist == ""):
@@ -730,12 +840,10 @@ Thanks,
         return policy.source_excludes_rat or []
 
     @property
-    def policy_strict_checking(self) -> bool:
-        # This is bool, so it should never be None
-        # TODO: Should we make it nullable for defaulting?
+    def policy_tagging_spec(self) -> dict[str, Any] | None:
         if (policy := self.release_policy) is None:
-            return False
-        return policy.strict_checking
+            return None
+        return policy.file_tag_mappings
 
     @property
     def policy_github_repository_name(self) -> str:
@@ -784,12 +892,13 @@ Thanks,
 class Release(sqlmodel.SQLModel, table=True):
     # model_config = compat.SQLModelConfig(extra="forbid", 
from_attributes=True)
 
-    # We guarantee that "{project.name}-{version}" is unique
-    # Therefore we can use that for the name
-    name: str = sqlmodel.Field(default="", primary_key=True, unique=True, 
**example("example-0.0.1"))
+    # We guarantee that "{project.key}-{version}" is unique
+    # Therefore we can use that for the key
+    key: str = sqlmodel.Field(default="", primary_key=True, unique=True, 
**example("example-0.0.1"))
     phase: ReleasePhase = 
sqlmodel.Field(**example(ReleasePhase.RELEASE_CANDIDATE_DRAFT))
     created: datetime.datetime = sqlmodel.Field(
-        sa_column=sqlalchemy.Column(UTCDateTime), 
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC))
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+        **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
     released: datetime.datetime | None = sqlmodel.Field(
         default=None,
@@ -797,21 +906,25 @@ class Release(sqlmodel.SQLModel, table=True):
         **example(datetime.datetime(2025, 6, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
 
+    check_cache_key: str | None = sqlmodel.Field(default=None, 
**example("ef0ccb0a-3514-4b65-abcd-879850349f74"))
+
     # M-1: Release -> Project
     # 1-M: Project -> [Release]
-    project_name: str = sqlmodel.Field(foreign_key="project.name", 
**example("example"))
+    project_key: str = sqlmodel.Field(foreign_key="project.key", 
**example("example"))
     project: Project = sqlmodel.Relationship(back_populates="releases")
     see_also(Project.releases)
 
     package_managers: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example([])
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False), **example([])
     )
     # TODO: Not all releases have a version
     # We could either make this str | None, or we could require version to be 
set on packages only
     # For example, Apache Airflow Providers do not have an overall version
     # They have one version per package, i.e. per provider
     version: str = sqlmodel.Field(**example("0.0.1"))
-    sboms: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON), **example([]))
+    sboms: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False), **example([])
+    )
 
     # 1-1: Release -C-> ReleasePolicy
     # 1-1: ReleasePolicy -> Release
@@ -821,7 +934,9 @@ class Release(sqlmodel.SQLModel, table=True):
     )
 
     # VoteEntry is a Pydantic model, not a SQL model
-    votes: list[VoteEntry] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    votes: list[VoteEntry] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
+    )
     vote_manual: bool = sqlmodel.Field(default=False, **example(False))
     vote_started: datetime.datetime | None = sqlmodel.Field(
         default=None,
@@ -841,7 +956,7 @@ class Release(sqlmodel.SQLModel, table=True):
         back_populates="release",
         sa_relationship_kwargs={
             "order_by": "Revision.seq",
-            "foreign_keys": "[Revision.release_name]",
+            "foreign_keys": "[Revision.release_key]",
             "cascade": "all, delete-orphan",
         },
     )
@@ -858,8 +973,8 @@ class Release(sqlmodel.SQLModel, table=True):
         back_populates="release", sa_relationship_kwargs={"cascade": "all, 
delete-orphan"}
     )
 
-    # The combination of project_name and version must be unique
-    __table_args__ = (sqlmodel.UniqueConstraint("project_name", "version", 
name="unique_project_version"),)
+    # The combination of key and version must be unique
+    __table_args__ = (sqlmodel.UniqueConstraint("project_key", "version", 
name="unique_project_version"),)
 
     @property
     def committee(self) -> Committee | None:
@@ -870,6 +985,26 @@ class Release(sqlmodel.SQLModel, table=True):
         #     return None
         return project.committee
 
+    @property
+    def safe_latest_revision_number(self) -> safe.RevisionNumber:
+        """Get the typesafe validated name for the Release"""
+        return safe.RevisionNumber(self.unwrap_revision_number)
+
+    @property
+    def safe_key(self) -> safe.ReleaseKey:
+        """Get the typesafe validated name for the Release"""
+        return safe.ReleaseKey(self.key)
+
+    @property
+    def safe_project_key(self) -> safe.ProjectKey:
+        """Get the typesafe validated name for the release project"""
+        return safe.ProjectKey(self.project_key)
+
+    @property
+    def safe_version_key(self) -> safe.VersionKey:
+        """Get the typesafe validated name for the release version"""
+        return safe.VersionKey(self.version)
+
     @property
     def short_display_name(self) -> str:
         """Get the short display name for the release."""
@@ -908,7 +1043,7 @@ class Release(sqlmodel.SQLModel, table=True):
     # def latest_revision_number_query(self) -> expression.ScalarSelect[str]:
     #     return (
     #         sqlmodel.select(validate_instrumented_attribute(Revision.number))
-    #         .where(validate_instrumented_attribute(Revision.release_name) == 
Release.name)
+    #         .where(validate_instrumented_attribute(Revision.release_key) == 
Release.name)
     #         .order_by(validate_instrumented_attribute(Revision.seq).desc())
     #         .limit(1)
     #         .scalar_subquery()
@@ -925,20 +1060,22 @@ class CheckResult(sqlmodel.SQLModel, table=True):
 
     # M-1: CheckResult -> Release
     # 1-M: Release -C-> [CheckResult]
-    release_name: str = sqlmodel.Field(
-        foreign_key="release.name", ondelete="CASCADE", index=True, 
**example("example-0.0.1")
+    release_key: str = sqlmodel.Field(
+        foreign_key="release.key", ondelete="CASCADE", index=True, 
**example("example-0.0.1")
     )
     release: Release = sqlmodel.Relationship(back_populates="check_results")
 
     # We don't call this latest_revision_number, because it might not be the 
latest
     revision_number: str | None = sqlmodel.Field(default=None, index=True, 
**example("00005"))
     checker: str = sqlmodel.Field(**example("atr.tasks.checks.license.files"))
+    checker_version: str | None = sqlmodel.Field(default=None, **example("2"))
+
     primary_rel_path: str | None = sqlmodel.Field(
         default=None, index=True, 
**example("apache-example-0.0.1-source.tar.gz")
     )
     member_rel_path: str | None = sqlmodel.Field(default=None, index=True, 
**example("apache-example-0.0.1/pom.xml"))
     created: datetime.datetime = sqlmodel.Field(
-        sa_column=sqlalchemy.Column(UTCDateTime),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
         **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
     status: CheckResultStatus = 
sqlmodel.Field(default=CheckResultStatus.SUCCESS, 
**example(CheckResultStatus.SUCCESS))
@@ -946,18 +1083,23 @@ class CheckResult(sqlmodel.SQLModel, table=True):
     data: Any = sqlmodel.Field(
         sa_column=sqlalchemy.Column(sqlalchemy.JSON), **example({"expected": 
"...", "found": "..."})
     )
-    input_hash: str | None = sqlmodel.Field(default=None, index=True, 
**example("blake3:7f83b1657ff1fc..."))
+    inputs_hash: str | None = sqlmodel.Field(default=None, index=True, 
**example("blake3:7f83b1657ff1fc..."))
     cached: bool = sqlmodel.Field(default=False, **example(False))
 
+    @property
+    def safe_primary_rel_path(self) -> safe.RelPath | None:
+        """Get the typesafe validated relative path for the check result, if 
set."""
+        return safe.RelPath(self.primary_rel_path) if self.primary_rel_path 
else None
+
 
 class CheckResultIgnore(sqlmodel.SQLModel, table=True):
     id: int = sqlmodel.Field(default=None, primary_key=True, **example(123))
     asf_uid: str = sqlmodel.Field(**example("user"))
     created: datetime.datetime = sqlmodel.Field(
-        sa_column=sqlalchemy.Column(UTCDateTime),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
         **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
-    project_name: str = sqlmodel.Field(foreign_key="project.name", 
**example("example"))
+    project_key: str = sqlmodel.Field(foreign_key="project.key", 
**example("example"))
     release_glob: str | None = sqlmodel.Field(**example("example-0.0.*"))
     revision_number: str | None = sqlmodel.Field(**example("00001"))
     checker_glob: str | None = 
sqlmodel.Field(**example("atr.tasks.checks.license.files"))
@@ -976,7 +1118,7 @@ class CheckResultIgnore(sqlmodel.SQLModel, table=True):
 
 # Distribution: Release
 class Distribution(sqlmodel.SQLModel, table=True):
-    release_name: str = sqlmodel.Field(primary_key=True, index=True, 
foreign_key="release.name", ondelete="CASCADE")
+    release_key: str = sqlmodel.Field(foreign_key="release.key", 
ondelete="CASCADE", primary_key=True, index=True)
     release: Release = sqlmodel.Relationship(back_populates="distributions")
     platform: DistributionPlatform = sqlmodel.Field(primary_key=True, 
index=True)
     owner_namespace: str = sqlmodel.Field(primary_key=True, index=True, 
default="")
@@ -993,6 +1135,18 @@ class Distribution(sqlmodel.SQLModel, table=True):
     # So we do not store it in the database
     # api_response: Any = 
sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
 
+    def distribution_data(self, details: bool = False) -> "distribution.Data":
+        """Get a distribution data object"""
+        from . import distribution
+
+        return distribution.Data(
+            platform=self.platform,
+            owner_namespace=safe.Alphanumeric(self.owner_namespace),
+            package=safe.Alphanumeric(self.package),
+            version=safe.VersionKey(self.version),
+            details=details,
+        )
+
     @property
     def identifier(self) -> str:
         def normal(text: str) -> str:
@@ -1003,6 +1157,11 @@ class Distribution(sqlmodel.SQLModel, table=True):
         version = normal(self.version)
         return f"{name}-{package}-{version}"
 
+    @property
+    def safe_release_key(self) -> safe.ReleaseKey:
+        """Get the typesafe validated name for the distribution release"""
+        return safe.ReleaseKey(self.release_key)
+
     @property
     def title(self) -> str:
         return f"{self.platform.value.name} {self.package} {self.version}"
@@ -1017,7 +1176,7 @@ class Distribution(sqlmodel.SQLModel, table=True):
 #     is_test: bool = sqlmodel.Field(default=False)
 #     automation_endpoint: str
 #
-#     project_name: str = sqlmodel.Field(foreign_key="project.name")
+#     project_key: str = sqlmodel.Field(foreign_key="project.name")
 #
 #     # M-1: DistributionChannel -> Project
 #     # 1-M: Project -> [DistributionChannel]
@@ -1037,7 +1196,8 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
     length: int = sqlmodel.Field(**example(4096))
     # Creation date
     created: datetime.datetime = sqlmodel.Field(
-        sa_column=sqlalchemy.Column(UTCDateTime), 
**example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC))
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+        **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
     # Latest self signature
     latest_self_signature: datetime.datetime | None = sqlmodel.Field(
@@ -1049,7 +1209,9 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
     primary_declared_uid: str | None = sqlmodel.Field(**example("User 
<[email protected]>"))
     # The secondary UIDs declared in the key
     secondary_declared_uids: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON), 
**example(["User <[email protected]>"])
+        default_factory=list,
+        sa_column=sqlalchemy.Column(sqlalchemy.JSON, nullable=False),
+        **example(["User <[email protected]>"]),
     )
     # The UID used by Apache, if available
     apache_uid: str | None = sqlmodel.Field(**example("user"))
@@ -1073,24 +1235,100 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
             self.expires = 
datetime.datetime.fromisoformat(self.expires.rstrip("Z"))
 
 
+# Quarantined: Release
+class Quarantined(sqlmodel.SQLModel, table=True):
+    id: int | None = sqlmodel.Field(default=None, primary_key=True)
+
+    # M-1: Quarantined -> Release
+    release_key: str = sqlmodel.Field(
+        foreign_key="release.key", ondelete="CASCADE", index=True, 
**example("example-0.0.1")
+    )
+    release: Release = sqlmodel.Relationship(
+        sa_relationship_kwargs={
+            "foreign_keys": "[Quarantined.release_key]",
+        },
+    )
+
+    asf_uid: str = sqlmodel.Field(**example("user"))
+    prior_revision_key: str | None = sqlmodel.Field(default=None, 
**example("example-0.0.1 00005"))
+    status: QuarantineStatus = sqlmodel.Field(
+        default=QuarantineStatus.PENDING, index=True, 
**example(QuarantineStatus.PENDING)
+    )
+    token: str = sqlmodel.Field(**example("0123456789abcdef0123456789abcdef"))
+    created: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+        **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
+    )
+    completed: datetime.datetime | None = sqlmodel.Field(
+        default=None,
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=True),
+        **example(datetime.datetime(2025, 5, 1, 1, 32, 3, 
tzinfo=datetime.UTC)),
+    )
+    file_metadata: list[QuarantineFileEntryV1] | None = sqlmodel.Field(
+        default=None, sa_column=sqlalchemy.Column(QuarantineFileMetadataJSON)
+    )
+    use_check_cache: bool = sqlmodel.Field(default=True, **example(True))
+    description: str | None = sqlmodel.Field(default=None, **example("Upload 
from web compose flow"))
+
+    def model_post_init(self, _context):
+        if isinstance(self.created, str):
+            self.created = 
datetime.datetime.fromisoformat(self.created.rstrip("Z"))
+
+        if isinstance(self.completed, str):
+            self.completed = 
datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
+
+        if isinstance(self.status, str):
+            self.status = QuarantineStatus(self.status)
+
+
+# ReleaseFileState: Revision
+class ReleaseFileState(sqlmodel.SQLModel, table=True):
+    release_key: str = sqlmodel.Field(primary_key=True, 
**example("example-0.0.1"))
+    path: str = sqlmodel.Field(primary_key=True, 
**example("apache-example-0.0.1-src.tar.gz"))
+    since_revision_seq: int = sqlmodel.Field(primary_key=True, **example(1))
+    present: bool = sqlmodel.Field(**example(True))
+    content_hash: str | None = sqlmodel.Field(default=None, 
**example("blake3:7f83b1657ff1fc..."))
+    classification: str | None = sqlmodel.Field(default=None, 
**example("source"))
+
+    __table_args__ = (
+        sqlalchemy.ForeignKeyConstraint(
+            ["release_key", "since_revision_seq"],
+            ["revision.release_key", "revision.seq"],
+            ondelete="CASCADE",
+        ),
+        sqlalchemy.CheckConstraint(
+            """
+            (
+                (present = 1 AND content_hash IS NOT NULL AND classification 
IS NOT NULL)
+                OR
+                (present = 0 AND content_hash IS NULL AND classification IS 
NULL)
+            )
+            """,
+            name="valid_release_file_state",
+        ),
+    )
+
+
 # ReleasePolicy: Project
 class ReleasePolicy(sqlmodel.SQLModel, table=True):
     id: int = sqlmodel.Field(default=None, primary_key=True)
-    mailto_addresses: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    mailto_addresses: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
+    )
     manual_vote: bool = sqlmodel.Field(default=False)
     min_hours: int | None = sqlmodel.Field(default=None)
     release_checklist: str = sqlmodel.Field(default="")
     vote_comment_template: str = sqlmodel.Field(default="")
-    pause_for_rm: bool = sqlmodel.Field(default=False)
     start_vote_subject: str = sqlmodel.Field(default="")
     start_vote_template: str = sqlmodel.Field(default="")
     announce_release_subject: str = sqlmodel.Field(default="")
     announce_release_template: str = sqlmodel.Field(default="")
     binary_artifact_paths: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
     )
     source_artifact_paths: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
     )
     license_check_mode: LicenseCheckMode = 
sqlmodel.Field(default=LicenseCheckMode.BOTH)
     source_excludes_lightweight: list[str] = sqlmodel.Field(
@@ -1099,7 +1337,6 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
     source_excludes_rat: list[str] = sqlmodel.Field(
         default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON, 
nullable=False)
     )
-    strict_checking: bool = sqlmodel.Field(default=False)
     github_repository_name: str = sqlmodel.Field(default="")
     github_repository_branch: str = sqlmodel.Field(default="")
     github_compose_workflow_path: list[str] = sqlmodel.Field(
@@ -1128,7 +1365,6 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
             min_hours=self.min_hours,
             release_checklist=self.release_checklist,
             vote_comment_template=self.vote_comment_template,
-            pause_for_rm=self.pause_for_rm,
             start_vote_subject=self.start_vote_subject,
             start_vote_template=self.start_vote_template,
             announce_release_subject=self.announce_release_subject,
@@ -1138,7 +1374,6 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
             license_check_mode=self.license_check_mode,
             source_excludes_lightweight=list(self.source_excludes_lightweight),
             source_excludes_rat=list(self.source_excludes_rat),
-            strict_checking=self.strict_checking,
             github_repository_name=self.github_repository_name,
             github_repository_branch=self.github_repository_branch,
             
github_compose_workflow_path=list(self.github_compose_workflow_path),
@@ -1150,15 +1385,15 @@ class ReleasePolicy(sqlmodel.SQLModel, table=True):
 
 # Revision: Release
 class Revision(sqlmodel.SQLModel, table=True):
-    name: str = sqlmodel.Field(default="", primary_key=True, unique=True, 
**example("example-0.0.1 00002"))
+    key: str = sqlmodel.Field(default="", primary_key=True, unique=True, 
**example("example-0.0.1 00002"))
 
     # M-1: Revision -> Release
     # 1-M: Release -C-> [Revision]
-    release_name: str | None = sqlmodel.Field(default=None, 
foreign_key="release.name", **example("example-0.0.1"))
+    release_key: str | None = sqlmodel.Field(default=None, 
foreign_key="release.key", **example("example-0.0.1"))
     release: Release = sqlmodel.Relationship(
         back_populates="revisions",
         sa_relationship_kwargs={
-            "foreign_keys": "[Revision.release_name]",
+            "foreign_keys": "[Revision.release_key]",
         },
     )
 
@@ -1169,21 +1404,19 @@ class Revision(sqlmodel.SQLModel, table=True):
     asfuid: str = sqlmodel.Field(**example("user"))
     created: datetime.datetime = sqlmodel.Field(
         default_factory=lambda: datetime.datetime.now(datetime.UTC),
-        sa_column=sqlalchemy.Column(UTCDateTime),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
         **example(datetime.datetime(2025, 5, 1, 1, 2, 3, tzinfo=datetime.UTC)),
     )
     phase: ReleasePhase = 
sqlmodel.Field(**example(ReleasePhase.RELEASE_CANDIDATE_DRAFT))
 
     # 1-1: Revision -> Revision
     # 1-1: Revision -> Revision
-    parent_name: str | None = sqlmodel.Field(
-        default=None, foreign_key="revision.name", **example("example-0.0.1 
00001")
-    )
+    parent_key: str | None = sqlmodel.Field(default=None, 
foreign_key="revision.key", **example("example-0.0.1 00001"))
     parent: Optional["Revision"] = sqlmodel.Relationship(
         sa_relationship_kwargs=dict(
-            remote_side=lambda: Revision.name,
+            remote_side=lambda: Revision.key,
             uselist=False,
-            primaryjoin=lambda: Revision.parent_name == Revision.name,
+            primaryjoin=lambda: Revision.parent_key == Revision.key,
             back_populates="child",
         )
     )
@@ -1193,8 +1426,14 @@ class Revision(sqlmodel.SQLModel, table=True):
     child: Optional["Revision"] = 
sqlmodel.Relationship(back_populates="parent")
 
     description: str | None = sqlmodel.Field(default=None, **example("This is 
a description"))
+    merge_base_revision_key: str | None = sqlmodel.Field(default=None, 
**example("example-0.0.1 00001"))
     tag: str | None = sqlmodel.Field(default=None, **example("rc1"))
-    use_check_cache: bool = sqlmodel.Field(default=True, **example(True))
+    was_quarantined: bool = sqlmodel.Field(default=False, **example(False))
+
+    @property
+    def safe_number(self) -> safe.RevisionNumber:
+        """Get the typesafe validated number for the revision"""
+        return safe.RevisionNumber(self.number)
 
     def model_post_init(self, _context):
         if isinstance(self.created, str):
@@ -1204,8 +1443,8 @@ class Revision(sqlmodel.SQLModel, table=True):
             self.phase = ReleasePhase(self.phase)
 
     __table_args__ = (
-        sqlmodel.UniqueConstraint("release_name", "seq", 
name="uq_revision_release_seq"),
-        sqlmodel.UniqueConstraint("release_name", "number", 
name="uq_revision_release_number"),
+        sqlmodel.UniqueConstraint("release_key", "seq", 
name="uq_revision_release_seq"),
+        sqlmodel.UniqueConstraint("release_key", "number", 
name="uq_revision_release_number"),
     )
 
 
@@ -1213,35 +1452,35 @@ class Revision(sqlmodel.SQLModel, table=True):
 class WorkflowStatus(sqlmodel.SQLModel, table=True):
     workflow_id: str = sqlmodel.Field(primary_key=True, index=True)
     run_id: int = sqlmodel.Field(primary_key=True, index=True)
-    project_name: str = sqlmodel.Field(index=True)
+    project_key: str = sqlmodel.Field(index=True)
     task_id: int | None = sqlmodel.Field(default=None, foreign_key="task.id", 
ondelete="SET NULL")
     task: Task = sqlmodel.Relationship(back_populates="workflow")
     status: str = sqlmodel.Field()
     message: str | None = sqlmodel.Field(default=None)
 
 
-def revision_name(release_name: str, number: str) -> str:
-    return f"{release_name} {number}"
+def revision_key(release_key: safe.ReleaseKey | str, number: str) -> str:
+    return f"{release_key} {number}"
 
 
 @event.listens_for(Revision, "before_insert")
-def populate_revision_sequence_and_name(
+def populate_revision_sequence_and_key(
     _mapper: orm.Mapper, connection: sqlalchemy.engine.Connection, revision: 
Revision
 ) -> None:
-    # We require Revision.release_name to have been set
-    if not revision.release_name:
+    # We require Revision.release_key to have been set
+    if not revision.release_key:
         # Raise an exception
-        # Otherwise, Revision.name would be "", Revision.seq 0, and 
Revision.number ""
-        raise RuntimeError("Cannot populate revision sequence and name without 
release_name")
+        # Otherwise, Revision.key would be "", Revision.seq 0, and 
Revision.number ""
+        raise RuntimeError("Cannot populate revision sequence and key without 
release_key")
 
     # Allocate the next sequence number from the counter table
     # This ensures that sequence numbers are never reused, even after release 
deletion
     # Uses ON CONFLICT DO UPDATE with RETURNING
     upsert_stmt = (
         sqlite.insert(RevisionCounter)
-        .values(release_name=revision.release_name, last_allocated_number=1)
+        .values(release_key=revision.release_key, last_allocated_number=1)
         .on_conflict_do_update(
-            index_elements=["release_name"],
+            index_elements=["release_key"],
             set_={"last_allocated_number": 
sqlalchemy.text("last_allocated_number + 1")},
         )
         .returning(sqlalchemy.literal_column("last_allocated_number"))
@@ -1251,49 +1490,60 @@ def populate_revision_sequence_and_name(
 
     revision.seq = new_seq
     revision.number = str(new_seq).zfill(5)
-    revision.name = revision_name(revision.release_name, revision.number)
+    revision.key = revision_key(revision.release_key, revision.number)
 
     # Find the actual parent for the parent_name foreign key
     # We cannot assume that the parent exists
     parent_stmt = (
-        sqlmodel.select(validate_instrumented_attribute(Revision.name))
-        .where(validate_instrumented_attribute(Revision.release_name) == 
revision.release_name)
+        sqlmodel.select(validate_instrumented_attribute(Revision.key))
+        .where(validate_instrumented_attribute(Revision.release_key) == 
revision.release_key)
         
.order_by(sqlalchemy.desc(validate_instrumented_attribute(Revision.seq)))
         .limit(1)
     )
     parent_row = connection.execute(parent_stmt).fetchone()
     if parent_row is not None:
-        revision.parent_name = parent_row[0]
+        revision.parent_key = parent_row[0]
 
 
 @event.listens_for(Release, "before_insert")
-def check_release_name(_mapper: orm.Mapper, _connection: 
sqlalchemy.Connection, release: Release) -> None:
-    if release.name == "":
+def check_release_key(_mapper: orm.Mapper, _connection: sqlalchemy.Connection, 
release: Release) -> None:
+    if release.key == "":
         # Quiet the type checker
-        project_name = getattr(release, "project_name", None)
+        project_key = getattr(release, "project_key", None)
         version = getattr(release, "version", None)
-        if (project_name is None) or (version is None):
-            raise ValueError("Cannot generate release name without 
project_name and version")
-        release.name = release_name(project_name, version)
+        if (project_key is None) or (version is None):
+            raise ValueError("Cannot generate release key without project_key 
and version")
+        release.key = release_key(project_key, version)
 
 
-def latest_revision_number_query(release_name: str | None = None) -> 
expression.ScalarSelect[str]:
-    if release_name is None:
-        query_release_name = Release.name
+def latest_revision_number_query(release_key: str | None = None) -> 
expression.ScalarSelect[str]:
+    if release_key is None:
+        query_release_key = Release.key
     else:
-        query_release_name = release_name
+        query_release_key = release_key
     return (
         sqlmodel.select(validate_instrumented_attribute(Revision.number))
-        .where(validate_instrumented_attribute(Revision.release_name) == 
query_release_name)
+        .where(validate_instrumented_attribute(Revision.release_key) == 
query_release_key)
         .order_by(validate_instrumented_attribute(Revision.seq).desc())
         .limit(1)
         .scalar_subquery()
     )
 
 
-def release_name(project_name: str, version_name: str) -> str:
+@overload
+def release_key(project_key: safe.ProjectKey, version_key: safe.VersionKey) -> 
safe.ReleaseKey: ...
+
+
+@overload
+def release_key(project_key: str, version_key: str) -> str: ...
+
+
+def release_key(project_key: safe.ProjectKey | str, version_key: 
safe.VersionKey | str) -> safe.ReleaseKey | str:
     """Return the release name for a given project and version."""
-    return f"{project_name}-{version_name}"
+    key = f"{project_key}-{version_key}"
+    if isinstance(project_key, safe.ProjectKey) and isinstance(version_key, 
safe.VersionKey):
+        return safe.ReleaseKey(key)
+    return key
 
 
 def validate_instrumented_attribute(obj: Any) -> orm.InstrumentedAttribute:
@@ -1305,7 +1555,7 @@ def validate_instrumented_attribute(obj: Any) -> 
orm.InstrumentedAttribute:
 
 RELEASE_LATEST_REVISION_NUMBER: Final = (
     sqlalchemy.select(validate_instrumented_attribute(Revision.number))
-    .where(validate_instrumented_attribute(Revision.release_name) == 
Release.name)
+    .where(validate_instrumented_attribute(Revision.release_key) == 
Release.key)
     .order_by(validate_instrumented_attribute(Revision.seq).desc())
     .limit(1)
     .correlate_except(Revision)
diff --git a/src/atrclient/models/tabulate.py b/src/atrclient/models/tabulate.py
index fdd0ff1..6790ce1 100644
--- a/src/atrclient/models/tabulate.py
+++ b/src/atrclient/models/tabulate.py
@@ -16,7 +16,6 @@
 # under the License.
 
 import enum
-from typing import Any, Literal
 
 import pydantic
 
@@ -37,19 +36,16 @@ class VoteStatus(enum.Enum):
     UNKNOWN = "Unknown"
 
 
-def example(value: Any) -> dict[Literal["json_schema_extra"], dict[str, Any]]:
-    return {"json_schema_extra": {"example": value}}
-
-
 class VoteEmail(schema.Strict):
-    asf_uid_or_email: str = schema.Field(..., **example("user"))
-    from_email: str = schema.Field(..., **example("[email protected]"))
-    status: VoteStatus = schema.Field(..., **example(VoteStatus.BINDING))
-    asf_eid: str = schema.Field(..., 
**example("[email protected]"))
-    iso_datetime: str = schema.Field(..., **example("2025-05-01T12:00:00Z"))
-    vote: Vote = schema.Field(..., **example(Vote.YES))
-    quotation: str = schema.Field(..., **example("+1 (Binding)"))
-    updated: bool = schema.Field(..., **example(True))
+    name: str = schema.example("Example User")
+    asf_uid_or_email: str = schema.example("user")
+    from_email: str = schema.example("[email protected]")
+    status: VoteStatus = schema.example(VoteStatus.BINDING)
+    asf_eid: str = 
schema.example("[email protected]")
+    iso_datetime: str = schema.example("2025-05-01T12:00:00Z")
+    vote: Vote = schema.example(Vote.YES)
+    quotation: str = schema.example("+1 (Binding)")
+    updated: bool = schema.example(True)
 
     @pydantic.field_validator("status", mode="before")
     @classmethod
@@ -63,24 +59,22 @@ class VoteEmail(schema.Strict):
 
 
 class VoteDetails(schema.Strict):
-    start_unixtime: int | None = schema.Field(..., **example(1714435200))
-    votes: dict[str, VoteEmail] = schema.Field(
-        ...,
-        **example(
-            {
-                "user": VoteEmail(
-                    asf_uid_or_email="user",
-                    from_email="[email protected]",
-                    status=VoteStatus.BINDING,
-                    asf_eid="[email protected]",
-                    iso_datetime="2025-05-01T12:00:00Z",
-                    vote=Vote.YES,
-                    quotation="+1 (Binding)",
-                    updated=True,
-                )
-            }
-        ),
+    start_unixtime: int | None = schema.example(1714435200)
+    votes: dict[str, VoteEmail] = schema.example(
+        {
+            "user": VoteEmail(
+                name="Example User",
+                asf_uid_or_email="user",
+                from_email="[email protected]",
+                status=VoteStatus.BINDING,
+                asf_eid="[email protected]",
+                iso_datetime="2025-05-01T12:00:00Z",
+                vote=Vote.YES,
+                quotation="+1 (Binding)",
+                updated=True,
+            )
+        }
     )
-    summary: dict[str, int] = schema.Field(..., **example({"user": 1}))
-    passed: bool = schema.Field(..., **example(True))
-    outcome: str = schema.Field(..., **example("The vote passed."))
+    summary: dict[str, int] = schema.example({"user": 1})
+    passed: bool = schema.example(True)
+    outcome: str = schema.example("The vote passed.")
diff --git a/src/atrclient/models/attestable.py b/src/atrclient/models/unsafe.py
similarity index 63%
copy from src/atrclient/models/attestable.py
copy to src/atrclient/models/unsafe.py
index 3cff655..417f845 100644
--- a/src/atrclient/models/attestable.py
+++ b/src/atrclient/models/unsafe.py
@@ -14,20 +14,23 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import NewType
 
-from typing import Annotated, Literal
 
-import pydantic
+class UnsafeStr:
+    """A raw string from URL routing that has not been validated."""
 
-from . import schema
+    __slots__ = ("_value",)
 
+    def __init__(self, value: str) -> None:
+        self._value = value
 
-class HashEntry(schema.Strict):
-    size: int
-    uploaders: list[Annotated[tuple[str, str], 
pydantic.BeforeValidator(tuple)]]
+    def __repr__(self) -> str:
+        return f"UnsafeStr({self._value!r})"
 
+    def __str__(self) -> str:
+        return self._value
 
-class AttestableV1(schema.Strict):
-    version: Literal[1] = 1
-    paths: dict[str, str] = schema.factory(dict)
-    hashes: dict[str, HashEntry] = schema.factory(dict)
+
+# The Path type exists so we can give Quart a hint for type conversions
+Path = NewType("Path", UnsafeStr)
diff --git a/uv.lock b/uv.lock
index 9cd0820..956f741 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
 requires-python = ">=3.12"
 
 [options]
-exclude-newer = "2026-04-06T16:33:00Z"
+exclude-newer = "2026-04-06T16:43:00Z"
 
 [[package]]
 name = "aiohappyeyeballs"
@@ -136,7 +136,7 @@ wheels = [
 
 [[package]]
 name = "apache-trusted-releases"
-version = "0.20260406.1633"
+version = "0.20260406.1643"
 source = { editable = "." }
 dependencies = [
     { name = "aiohttp" },


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

Reply via email to