You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/07/02 16:15:17 UTC

(tooling-trusted-releases) 01/01: Use the contingent approvals service to archive or delete projects

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

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

commit 18eb7fcf5a8642f00c5771b5c27a714d3d020fc4
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Jul 2 17:13:59 2026 +0100

    Use the contingent approvals service to archive or delete projects
---
 atr/config.py                                   |   2 +
 atr/constants.py                                |  14 +
 atr/db/__init__.py                              |  31 ++
 atr/get/projects.py                             |  92 +++--
 atr/models/__init__.py                          |   2 +
 atr/models/args.py                              |   5 +
 atr/models/{__init__.py => cap.py}              |  52 ++-
 atr/models/results.py                           |   7 +-
 atr/models/sql.py                               |  45 +++
 atr/post/projects.py                            | 266 ++++++++++++--
 atr/shared/projects.py                          |   4 +
 atr/ssh.py                                      |   1 +
 atr/storage/__init__.py                         |   7 +
 atr/storage/writers/project.py                  | 107 +++++-
 atr/storage/writers/release.py                  |   1 +
 atr/tasks/__init__.py                           |  31 ++
 atr/tasks/cap.py                                | 123 +++++++
 atr/util.py                                     |  87 +++++
 migrations/versions/0102_2026.06.30_258b186f.py |  75 ++++
 tests/unit/test_cap.py                          | 458 ++++++++++++++++++++++++
 tests/unit/test_project_writer_delete.py        |  61 +++-
 21 files changed, 1369 insertions(+), 102 deletions(-)

diff --git a/atr/config.py b/atr/config.py
index 38a11189..5c58b85f 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -92,6 +92,8 @@ class AppConfig:
     SVN_TOKEN = _config_secrets("SVN_TOKEN", STATE_DIR, default=None, cast=str)
     SVN_PUBLISH_URL = _config_secrets("SVN_PUBLISH_URL", STATE_DIR, default=None, cast=str)
     GITHUB_TOKEN = _config_secrets("GITHUB_TOKEN", STATE_DIR, default=None, cast=str)
+    CAP_API_BASE_URL = decouple.config("CAP_API_BASE_URL", default="https://cap-test.apache.org")
+    CAP_ROLE_ACCOUNT_TOKEN = _config_secrets("CAP_ROLE_ACCOUNT_TOKEN", STATE_DIR, default=None, cast=str)
 
     DEBUG = False
     TEMPLATES_AUTO_RELOAD = False
diff --git a/atr/constants.py b/atr/constants.py
index 29231ea9..5060759a 100644
--- a/atr/constants.py
+++ b/atr/constants.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import datetime
 from typing import Final
 
 CLOSER_LUA_URL: Final[str] = "https://www.apache.org/dyn/closer.lua"
@@ -23,6 +24,19 @@ ARCHIVE_APACHE_URL: Final[str] = "https://archive.apache.org/dist"
 SVN_DIST_PUBLIC_URL: Final[str] = "https://dist.apache.org/repos/dist/atr"
 SVN_DIST_ROOT_URL: Final[str] = "https://dist.apache.org/repos/dist"
 
+CAP_VOTE_DURATION_MINUTES: Final[int] = 7 * 24 * 60
+CAP_ARCHIVE_APPROVAL_TYPE: Final[str] = "majority_approval"
+CAP_DELETE_APPROVAL_TYPE: Final[str] = "majority_approval"
+CAP_RESOLVE_INITIAL_DELAY: Final[datetime.timedelta] = datetime.timedelta(minutes=1)
+CAP_RESOLVE_RETRY_DELAYS: Final[tuple[datetime.timedelta, ...]] = (
+    datetime.timedelta(minutes=2),
+    datetime.timedelta(minutes=5),
+    datetime.timedelta(minutes=15),
+    datetime.timedelta(minutes=45),
+    datetime.timedelta(hours=2),
+    datetime.timedelta(hours=6),
+)
+
 APACHE_LICENSE_2_0: Final[str] = """\
 
                                  Apache License
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index a3c2dba1..b7d0698c 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -156,6 +156,37 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
     async def begin_immediate(self) -> None:
         await self.execute(sqlalchemy.text("BEGIN IMMEDIATE"))
 
+    def approval_request(
+        self,
+        id: Opt[int] = NOT_SET,
+        project_key: Opt[str] = NOT_SET,
+        action: Opt[sql.ApprovalAction] = NOT_SET,
+        cap_question_id: Opt[int] = NOT_SET,
+        status: Opt[sql.ApprovalStatus] = NOT_SET,
+        status_in: Opt[Sequence[sql.ApprovalStatus]] = NOT_SET,
+        project_key_in: Opt[Sequence[str]] = NOT_SET,
+    ) -> Query[sql.ApprovalRequest]:
+        query = sqlmodel.select(sql.ApprovalRequest)
+
+        via = sql.validate_instrumented_attribute
+
+        if is_defined(id):
+            query = query.where(sql.ApprovalRequest.id == id)
+        if is_defined(project_key):
+            query = query.where(sql.ApprovalRequest.project_key == project_key)
+        if is_defined(action):
+            query = query.where(sql.ApprovalRequest.action == action)
+        if is_defined(cap_question_id):
+            query = query.where(sql.ApprovalRequest.cap_question_id == cap_question_id)
+        if is_defined(status):
+            query = query.where(sql.ApprovalRequest.status == status)
+        if is_defined(status_in):
+            query = query.where(via(sql.ApprovalRequest.status).in_(status_in))
+        if is_defined(project_key_in):
+            query = query.where(via(sql.ApprovalRequest.project_key).in_(project_key_in))
+
+        return Query(self, query)
+
     def artifact(
         self,
         project_key: Opt[str] = NOT_SET,
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 1281daa3..4ef6ed54 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -161,6 +161,11 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
     """
     async with db.session() as data:
         projects = await data.project(_committee=True, _releases=True).order_by(sql.Project.name).all()
+        approvals = await data.approval_request(
+            status_in=[sql.ApprovalStatus.PENDING, sql.ApprovalStatus.APPROVED]
+        ).all()
+
+    approvals_by_project = {a.project_key: a for a in approvals}
 
     committee_project_counts: Counter[str] = Counter(
         str(p.committee.key) for p in projects if p.committee and p.is_active
@@ -173,6 +178,10 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
                 continue
             if not (user.is_committee_member(project.committee, session.uid) or session.is_admin):
                 continue
+            approval = approvals_by_project.get(str(project.key))
+            if approval is not None:
+                action_forms[str(project.key)] = await _approval_request_element(approval)
+                continue
             if project.committee and committee_project_counts[str(project.committee.key)] <= 1:
                 continue
             project_releases = project.releases_including_embargoed
@@ -182,10 +191,13 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
                     action=util.as_url(post.projects.delete),
                     form_classes=".d-inline-block.m-0",
                     submit_classes="btn-sm btn-outline-danger",
-                    submit_label="Delete project",
+                    submit_label="Request deletion",
                     empty=True,
                     defaults={"project_key": str(project.key)},
-                    confirm="Are you sure you want to delete this project? This cannot be undone.",
+                    confirm=(
+                        "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+                        " ready to delete only if the vote passes, and you must then return to complete it."
+                    ),
                 )
             elif all(r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT for r in project_releases):
                 action_forms[str(project.key)] = await form.render(
@@ -193,13 +205,13 @@ async def projects(session: web.Public, _projects: Literal["projects"]) -> str:
                     action=util.as_url(post.projects.archive),
                     form_classes=".d-inline-block.m-0",
                     submit_classes="btn-sm btn-outline-secondary",
-                    submit_label="Archive project",
+                    submit_label="Request archival",
                     empty=True,
                     defaults={"project_key": str(project.key)},
                     confirm=(
-                        f"This project has {util.plural(len(project_releases), 'draft release')}."
-                        " Archiving will delete those drafts and mark the project as retired."
-                        " Continue?"
+                        "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+                        " ready to archive only if the vote passes, and you must then return to complete it, which"
+                        " deletes the draft releases and retires the project."
                     ),
                 )
 
@@ -344,6 +356,28 @@ async def view(
     )
 
 
+async def _approval_request_element(approval: sql.ApprovalRequest) -> htm.Element:
+    verb = approval.action.value
+    if approval.status == sql.ApprovalStatus.APPROVED:
+        return await form.render(
+            shared.projects.CompleteApprovalRequest,
+            action=util.as_url(post.projects.complete_approval),
+            form_classes=".d-inline-block.m-0",
+            submit_classes="btn-sm btn-outline-danger",
+            submit_label=f"Complete {verb}",
+            empty=True,
+            defaults={"approval_request_id": approval.id},
+            confirm=f"The CAP approval vote passed. This will {verb} the project now. Continue?",
+        )
+    closes = approval.closes_at.strftime("%Y-%m-%d %H:%M UTC")
+    question_url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/#/question/{approval.cap_question_id}"
+    return htm.p(".text-muted.small.mb-0")[
+        "CAP approval vote ",
+        htm.a(href=question_url)[f"#{approval.cap_question_id}"],
+        f" to {verb} in progress (ends {closes}).",
+    ]
+
+
 def _asf_yaml_export(project: sql.Project) -> str:
     block: dict[str, object] = {"metadata": _asf_yaml_metadata(project)}
     policy = _asf_yaml_policy(project)
@@ -477,17 +511,27 @@ def _cycle_has_dates(cycle: sql.ProjectCycle) -> bool:
 
 
 async def _delete_form(project: sql.Project) -> htm.Element | None:
+    async with db.session() as data:
+        approval = await data.approval_request(
+            project_key=str(project.key),
+            status_in=[sql.ApprovalStatus.PENDING, sql.ApprovalStatus.APPROVED],
+        ).get()
+    if approval is not None:
+        return await _approval_request_element(approval)
     delete_form = None
     releases = project.releases_including_embargoed
     if not releases:
         delete_form = await form.render(
             shared.projects.DeleteSelectedProject,
             action=util.as_url(post.projects.delete),
-            form_classes="",
+            form_classes=".d-inline-block.m-0",
             submit_classes="btn-sm btn-outline-danger",
-            submit_label="Delete project",
+            submit_label="Request deletion",
             defaults={"project_key": str(project.key)},
-            confirm="Are you sure you want to delete this project? This cannot be undone.",
+            confirm=(
+                "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+                " ready to delete only if the vote passes, and you must then return to complete it."
+            ),
             empty=True,
         )
     elif all(r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT for r in releases):
@@ -496,13 +540,13 @@ async def _delete_form(project: sql.Project) -> htm.Element | None:
             action=util.as_url(post.projects.archive),
             form_classes=".d-inline-block.m-0",
             submit_classes="btn-sm btn-outline-secondary",
-            submit_label="Archive project",
+            submit_label="Request archival",
             empty=True,
             defaults={"project_key": str(project.key)},
             confirm=(
-                f"This project has {util.plural(len(releases), 'draft release')}."
-                " Archiving will delete those drafts and mark the project as retired."
-                " Continue?"
+                "This starts a binding CAP approval vote by the committee PMC. ATR will mark the project"
+                " ready to archive only if the vote passes, and you must then return to complete it, which"
+                " deletes the draft releases and retires the project."
             ),
         )
     return delete_form
@@ -695,7 +739,7 @@ async def _render_actions_card(
 ) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light")[htm.h3(".mb-2")["Actions"]]
-    with card.block(htm.div, classes=".card-body") as body:
+    with card.block(htm.div, classes=".card-body.d-flex.flex-column.align-items-start.gap-2") as body:
         if project.is_active and not is_sole_active_project:
             action_form = await _delete_form(project)
             if action_form:
@@ -704,20 +748,16 @@ async def _render_actions_card(
         if project.committee:
             if (project.committee.key in session.member_committees) or is_privileged:
                 body.append(
-                    htm.p[
-                        htm.a(
-                            ".btn.btn-sm.btn-outline-primary",
-                            href=util.as_url(add_project, committee_key=project.committee.key),
-                        )["Create a sibling project"]
-                    ]
+                    htm.a(
+                        ".btn.btn-sm.btn-outline-primary",
+                        href=util.as_url(add_project, committee_key=project.committee.key),
+                    )["Create a sibling project"]
                 )
                 body.append(
-                    htm.p[
-                        htm.a(
-                            ".btn.btn-sm.btn-outline-secondary",
-                            href=util.as_url(project_yaml, project_key=project.key),
-                        )["Export .asf.yaml"]
-                    ]
+                    htm.a(
+                        ".btn.btn-sm.btn-outline-secondary",
+                        href=util.as_url(project_yaml, project_key=project.key),
+                    )["Export .asf.yaml"]
                 )
     return card.collect()
 
diff --git a/atr/models/__init__.py b/atr/models/__init__.py
index 7a3d2470..0fbc8f0d 100644
--- a/atr/models/__init__.py
+++ b/atr/models/__init__.py
@@ -19,6 +19,7 @@ from . import (
     api,
     args,
     basic,
+    cap,
     distribution,
     github,
     helpers,
@@ -36,6 +37,7 @@ __all__ = [
     "api",
     "args",
     "basic",
+    "cap",
     "distribution",
     "github",
     "helpers",
diff --git a/atr/models/args.py b/atr/models/args.py
index 8865c219..c46de6fd 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -22,6 +22,11 @@ import pydantic
 from . import mail, safe, schema
 
 
+class CapApprovalResolveArgs(schema.Strict):
+    approval_request_id: int = schema.description("The ApprovalRequest row to resolve")
+    attempt: int = pydantic.Field(default=0, description="The current resolve attempt count")
+
+
 class ConvertCycloneDX(schema.Strict):
     """Arguments for the task to convert an artifact to a CycloneDX SBOM."""
 
diff --git a/atr/models/__init__.py b/atr/models/cap.py
similarity index 65%
copy from atr/models/__init__.py
copy to atr/models/cap.py
index 7a3d2470..69c0007b 100644
--- a/atr/models/__init__.py
+++ b/atr/models/cap.py
@@ -15,35 +15,23 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import (
-    api,
-    args,
-    basic,
-    distribution,
-    github,
-    helpers,
-    mail,
-    results,
-    safe,
-    schema,
-    sql,
-    tabulate,
-    validation,
-)
-
-# If we use .__name__, pyright gives a warning
-__all__ = [
-    "api",
-    "args",
-    "basic",
-    "distribution",
-    "github",
-    "helpers",
-    "mail",
-    "results",
-    "safe",
-    "schema",
-    "sql",
-    "tabulate",
-    "validation",
-]
+from . import schema
+
+
+class ErrorMessage(schema.Subset):
+    error: str
+
+
+class Question(schema.Subset):
+    question_id: int
+    outcome: str | None = None
+    permalink: str | None = None
+
+
+class Resolution(schema.Subset):
+    outcome: str | None = None
+    permalink: str | None = None
+
+
+class TokenIssued(schema.Subset):
+    token: str
diff --git a/atr/models/results.py b/atr/models/results.py
index b3d23e7c..831ee178 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -22,6 +22,10 @@ import pydantic
 from . import safe, schema
 
 
+class CapApprovalResolve(schema.Strict):
+    kind: Literal["cap_approval_resolve"] = schema.Field(alias="kind")
+
+
 class DistributionStatusCheck(schema.Strict):
     """Result of the task to check pending distribution statuses."""
 
@@ -279,7 +283,8 @@ class MetadataUpdate(schema.Strict):
 
 
 Results = Annotated[
-    DistributionStatusCheck
+    CapApprovalResolve
+    | DistributionStatusCheck
     | DistributionWorkflow
     | DistributionWorkflowStatus
     | HashingCheck
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 5976a9e6..6869c4ce 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -69,6 +69,19 @@ class DistributionPlatformValue:
 # Enumerations
 
 
+class ApprovalAction(enum.StrEnum):
+    ARCHIVE = "archive"
+    DELETE = "delete"
+
+
+class ApprovalStatus(enum.StrEnum):
+    PENDING = "pending"
+    APPROVED = "approved"
+    REJECTED = "rejected"
+    COMPLETED = "completed"
+    FAILED = "failed"
+
+
 class CheckResultStatus(enum.StrEnum):
     BLOCKER = "blocker"
     CONCERN = "concern"
@@ -217,6 +230,7 @@ class TaskStatus(enum.StrEnum):
 
 class TaskType(enum.StrEnum):
     ARCHIVE_COMPARISON = "archive_comparison"
+    CAP_APPROVAL_RESOLVE = "cap_approval_resolve"
     COMPARE_SOURCE_TREES = "compare_source_trees"
     DISTRIBUTION_STATUS = "distribution_status"
     DISTRIBUTION_WORKFLOW = "distribution_workflow"
@@ -253,6 +267,8 @@ class TaskType(enum.StrEnum):
         match self:
             case TaskType.ARCHIVE_COMPARISON:
                 return "Archive comparison"
+            case TaskType.CAP_APPROVAL_RESOLVE:
+                return "CAP approval resolution"
             case TaskType.COMPARE_SOURCE_TREES:
                 return "Compare source trees"
             case TaskType.DISTRIBUTION_STATUS:
@@ -552,6 +568,35 @@ def example(value: Any) -> dict[Literal["schema_extra"], dict[str, Any]]:
 # SQL models with no dependencies
 
 
+# ApprovalRequest:
+class ApprovalRequest(sqlmodel.SQLModel, table=True):
+    id: int | None = sqlmodel.Field(default=None, primary_key=True)
+    project_key: str = sqlmodel.Field()
+    committee_key: str = sqlmodel.Field()
+    action: ApprovalAction = sqlmodel.Field()
+    cap_question_id: int = sqlmodel.Field(unique=True)
+    status: ApprovalStatus = sqlmodel.Field(default=ApprovalStatus.PENDING, index=True)
+    outcome: str | None = None
+    requested_by: str = sqlmodel.Field()
+    requested_at: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC),
+        sa_column=sqlalchemy.Column(UTCDateTime, nullable=False),
+    )
+    closes_at: datetime.datetime = sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime, nullable=False))
+    resolved_at: datetime.datetime | None = sqlmodel.Field(default=None, sa_column=sqlalchemy.Column(UTCDateTime))
+    permalink: str | None = None
+    error: str | None = None
+
+    __table_args__ = (
+        sqlalchemy.Index(
+            "ix_approvalrequest_active_project",
+            "project_key",
+            unique=True,
+            sqlite_where=sqlalchemy.text("status IN ('PENDING', 'APPROVED')"),
+        ),
+    )
+
+
 # KeyLink:
 class KeyLink(sqlmodel.SQLModel, table=True):
     committee_key: str = sqlmodel.Field(foreign_key="committee.key", primary_key=True)
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 6018ee3f..4917e005 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -17,18 +17,25 @@
 
 from __future__ import annotations
 
+import datetime
 from typing import TYPE_CHECKING, Any, Final, Literal
 
 import quart
+import sqlalchemy.exc
 
 import atr.blueprints.post as post
+import atr.config as config
+import atr.constants as constants
 import atr.db as db
 import atr.get as get
+import atr.log as log
+import atr.models.cap
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.models.unsafe as unsafe
 import atr.shared as shared
 import atr.storage as storage
+import atr.tasks.cap as cap
 import atr.util as util
 import atr.web as web
 
@@ -74,37 +81,61 @@ async def archive(
 ) -> web.WerkzeugResponse:
     """
     URL: /project/archive
-    Archive a project as a PMC member. Any draft releases are deleted first - they
-    would otherwise be stranded, because release.delete rejects once a project is retired.
     """
-    project_key = archive_selected_project_form.project_key
+    return await _request_approval(session, archive_selected_project_form.project_key, sql.ApprovalAction.ARCHIVE)
 
-    # Pull the draft list up front so we can both delete them and report a count
+
+@post.typed
+async def complete_approval(
+    session: web.Committer,
+    _project_complete_approval: Literal["project/complete-approval"],
+    complete_approval_request_form: shared.projects.CompleteApprovalRequest,
+) -> web.WerkzeugResponse:
+    """
+    URL: /project/complete-approval
+    """
+    approval_request_id = complete_approval_request_form.approval_request_id
+    async with db.session() as data:
+        approval = await data.approval_request(id=approval_request_id).get()
+    if approval is None:
+        return await session.redirect(get.projects.projects, error="Approval request not found.")
+    if approval.status != sql.ApprovalStatus.APPROVED:
+        return await session.redirect(get.projects.projects, error="This approval request is not ready to complete.")
+
+    project_key = safe.ProjectKey(approval.project_key)
     async with db.session() as data:
         project = await data.project(key=str(project_key), _releases=True).get()
-        draft_versions: list[safe.VersionKey] = (
-            [
-                safe.VersionKey(r.version)
-                for r in project.releases_including_embargoed
-                if r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
-            ]
-            if project is not None
-            else []
+        if (project is None) or (project.committee is None):
+            return await session.redirect(get.projects.projects, error=f"Project '{project_key}' not found.")
+        active_siblings = len(
+            await data.project(
+                committee_key=project.committee.key, status=sql.ProjectStatus.ACTIVE, _committee=False
+            ).all()
         )
+        eligibility_error = _action_eligibility_error(
+            project, approval.action, active_siblings, completing=True, requested_at=approval.requested_at
+        )
+        if eligibility_error is not None:
+            return await session.redirect(get.projects.projects, error=eligibility_error)
+        draft_versions = [
+            safe.VersionKey(r.version)
+            for r in project.releases_including_embargoed
+            if r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+        ]
 
-    async with storage.write(session) as write:
-        wacm = await write.as_project_committee_member(project_key)
-        try:
-            for version in draft_versions:
-                await wacm.release.delete(project_key, version)
-            await wacm.project.archive(project_key)
-        except storage.AccessError as e:
-            return await session.redirect(get.projects.projects, error=f"Error archiving project: {e}")
+    try:
+        await _complete_action(session, approval.action, project_key, draft_versions, approval_request_id)
+    except storage.AccessError as e:
+        return await session.redirect(get.projects.projects, error=f"Error completing {approval.action.value}: {e}")
 
-    success = f"Project '{project_key}' archived successfully."
-    if draft_versions:
-        success += f" Deleted {util.plural(len(draft_versions), 'draft release')}."
-    return await session.redirect(get.projects.projects, success=success)
+    await cap.notify(
+        approval.requested_by,
+        f"Project {project_key} was {approval.action.value}d after CAP approval.",
+        sql.NotificationLevel.INFO,
+    )
+    return await session.redirect(
+        get.projects.projects, success=f"Project '{project_key}' {approval.action.value}d successfully."
+    )
 
 
 @post.typed
@@ -115,20 +146,8 @@ async def delete(
 ) -> web.WerkzeugResponse:
     """
     URL: /project/delete
-    Delete a project created by the user.
     """
-    project_key = delete_selected_project_form.project_key
-
-    async with storage.write(session) as write:
-        wacm = await write.as_project_committee_member(project_key)
-        try:
-            await wacm.project.delete(project_key)
-        except storage.AccessError as e:
-            # TODO: Redirect to committees
-            return await session.redirect(get.projects.projects, error=f"Error deleting project: {e}")
-
-    # TODO: Redirect to committees
-    return await session.redirect(get.projects.projects, success=f"Project '{project_key}' deleted successfully.")
+    return await _request_approval(session, delete_selected_project_form.project_key, sql.ApprovalAction.DELETE)
 
 
 @post.typed
@@ -144,6 +163,91 @@ async def view(
     return await _VIEW_HANDLERS[type(project_form)](session, project_form)
 
 
+def _action_eligibility_error(
+    project: sql.Project,
+    action: sql.ApprovalAction,
+    active_sibling_count: int,
+    *,
+    completing: bool = False,
+    requested_at: datetime.datetime | None = None,
+) -> str | None:
+    if not project.is_active:
+        return f"Project '{project.key}' is not active."
+    if active_sibling_count <= 1:
+        return f"Project '{project.key}' is the only active project in its committee."
+    releases = project.releases_including_embargoed
+    if action == sql.ApprovalAction.DELETE:
+        if releases:
+            return f"Project '{project.key}' has releases and cannot be deleted."
+        return None
+    if (not completing) and (not releases):
+        return f"Project '{project.key}' has no releases to archive."
+    if not all(r.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT for r in releases):
+        return f"Project '{project.key}' has non-draft releases and cannot be archived."
+    if not completing:
+        return None
+    new_drafts = [r for r in releases if (requested_at is None) or (r.created > requested_at)]
+    if new_drafts:
+        return (
+            f"Project '{project.key}' has {util.plural(len(new_drafts), 'draft release')} created after the"
+            " approval vote was requested. Delete them before completing the archival."
+        )
+    return None
+
+
+async def _complete_action(
+    session: web.Committer,
+    action: sql.ApprovalAction,
+    project_key: safe.ProjectKey,
+    draft_versions: list[safe.VersionKey],
+    approval_request_id: int,
+) -> None:
+    async with storage.write(session) as write:
+        wacm = await write.as_project_committee_member(project_key)
+        if action == sql.ApprovalAction.DELETE:
+            await wacm.project.delete(project_key, approval_request_id)
+            return
+        for version in draft_versions:
+            try:
+                await wacm.release.delete(project_key, version)
+            except storage.AccessError as e:
+                if e.status != 404:
+                    raise
+        await wacm.project.archive(project_key, approval_request_id)
+
+
+async def _create_cap_question(
+    action: sql.ApprovalAction,
+    project_key: safe.ProjectKey,
+    display_name: str,
+    committee_key: str,
+    requested_by: str,
+    closes_at: datetime.datetime,
+) -> atr.models.cap.Question:
+    token = await util.cap_mint_token()
+    verb = action.value
+    if action == sql.ApprovalAction.ARCHIVE:
+        approval_type = constants.CAP_ARCHIVE_APPROVAL_TYPE
+        consequence = "ATR will mark the project RETIRED"
+    else:
+        approval_type = constants.CAP_DELETE_APPROVAL_TYPE
+        consequence = "ATR will permanently delete the project and its metadata"
+    description = (
+        f"{requested_by} has requested, through ATR, to {verb} the project {project_key} ({display_name}). "
+        f"If this vote passes, {consequence}, and an authorised {committee_key} PMC member may complete the {verb} "
+        f"in ATR. This request was filed by Apache Trusted Releases on behalf of {requested_by}."
+    )
+    return await util.cap_create_question(
+        token,
+        project_id=committee_key,
+        title=f"[ATR] {verb.capitalize()} project {project_key}",
+        description=description,
+        target_audience=f"Binding voters: {committee_key} PMC members",
+        approval_type=approval_type,
+        closes_at=closes_at,
+    )
+
+
 async def _metadata_category_add(
     wacm: storage.WriteAsCommitteeMember, project_key: safe.ProjectKey, category_to_add: str
 ) -> bool:
@@ -184,6 +288,35 @@ async def _metadata_language_remove(
         return False
 
 
+async def _persist_approval(
+    session: web.Committer,
+    wacm: storage.WriteAsCommitteeMember,
+    project_key: safe.ProjectKey,
+    committee_key: str,
+    action: sql.ApprovalAction,
+    cap_question_id: int,
+    closes_at: datetime.datetime,
+) -> web.WerkzeugResponse:
+    try:
+        await wacm.project.request_approval(project_key, action, cap_question_id, closes_at)
+    except storage.AccessError as e:
+        log.warning(f"Concurrent CAP request for {project_key} lost. CAP question {cap_question_id} abandoned")
+        return await session.redirect(get.projects.projects, error=str(e))
+    except sqlalchemy.exc.SQLAlchemyError:
+        log.exception(f"Failed to record CAP approval for {project_key}. CAP question {cap_question_id} abandoned")
+        return await session.redirect(
+            get.projects.projects, error="Could not record the CAP approval vote. Please contact an administrator."
+        )
+
+    return await session.redirect(
+        get.projects.projects,
+        success=(
+            f"Approval vote created for the {committee_key} PMC (CAP #{cap_question_id}, closes "
+            f"{closes_at.strftime('%Y-%m-%d %H:%M UTC')}). ATR will mark it ready to complete once it passes."
+        ),
+    )
+
+
 async def _process_add_category(
     session: web.Committer, add_category_form: shared.projects.AddCategoryForm
 ) -> web.WerkzeugResponse:
@@ -480,6 +613,65 @@ async def _process_vote_form(session: web.Committer, vote_form: shared.projects.
     )
 
 
+async def _request_approval(
+    session: web.Committer, project_key: safe.ProjectKey, action: sql.ApprovalAction
+) -> web.WerkzeugResponse:
+    if not config.get().CAP_ROLE_ACCOUNT_TOKEN:
+        return await session.redirect(
+            get.projects.projects, error="CAP approval is not configured. Please contact an administrator."
+        )
+
+    async with storage.write(session) as write:
+        try:
+            wacm = await write.as_project_committee_member(project_key)
+        except storage.AccessError as e:
+            return await session.redirect(get.projects.projects, error=f"Error requesting {action.value} approval: {e}")
+
+        async with db.session() as data:
+            project = await data.project(key=str(project_key), _committee=True, _releases=True).get()
+            if (project is None) or (project.committee is None):
+                return await session.redirect(get.projects.projects, error=f"Project '{project_key}' not found.")
+            if project.committee.is_podling:
+                return await session.redirect(
+                    get.projects.projects,
+                    error=(
+                        "CAP approval voting is not available for podlings. Archival or deletion of a podling is an"
+                        " Incubator PMC decision."
+                    ),
+                )
+            committee_key = project.committee.key
+            display_name = project.display_name
+            active_siblings = len(
+                await data.project(committee_key=committee_key, status=sql.ProjectStatus.ACTIVE, _committee=False).all()
+            )
+            eligibility_error = _action_eligibility_error(project, action, active_siblings)
+            if eligibility_error is not None:
+                return await session.redirect(get.projects.projects, error=eligibility_error)
+            existing = await data.approval_request(
+                project_key=str(project_key),
+                status_in=[sql.ApprovalStatus.PENDING, sql.ApprovalStatus.APPROVED],
+            ).get()
+            if existing is not None:
+                return await session.redirect(
+                    get.projects.projects,
+                    error=f"A CAP approval request to {existing.action.value} this project is already in progress.",
+                )
+
+        closes_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
+            minutes=constants.CAP_VOTE_DURATION_MINUTES
+        )
+        try:
+            question = await _create_cap_question(
+                action, project_key, display_name, committee_key, session.uid, closes_at
+            )
+        except util.FetchError as e:
+            return await session.redirect(get.projects.projects, error=f"Could not create CAP approval vote: {e}")
+
+        return await _persist_approval(
+            session, wacm, project_key, committee_key, action, question.question_id, closes_at
+        )
+
+
 _VIEW_HANDLERS: Final[dict[type, Callable[[web.Committer, Any], Awaitable[web.WerkzeugResponse]]]] = {
     shared.projects.AddCategoryForm: _process_add_category,
     shared.projects.AddLanguageForm: _process_add_language,
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 8991074c..d2306d4a 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -498,6 +498,10 @@ class ArchiveSelectedProject(form.Form):
     project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
 
 
+class CompleteApprovalRequest(form.Form):
+    approval_request_id: form.Int = form.label("Approval request ID", widget=form.Widget.HIDDEN)
+
+
 class DeleteSelectedProject(form.Form):
     project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
 
diff --git a/atr/ssh.py b/atr/ssh.py
index bc378d8e..cda3c34b 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -710,6 +710,7 @@ async def _step_07c_ensure_release_object_for_write(project_key: safe.ProjectKey
     """Ensure the release object exists or create it for a write operation."""
     release_key = sql.release_key(str(project_key), str(version_key))
     async with db.session() as data:
+        await data.begin_immediate()
         release = await data.release(key=release_key, _committee=True).get()
         if release is None:
             project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True).demand(
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index a9f2deb6..08bcdf42 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -305,6 +305,13 @@ class WriteAsAsfYamlService(WriteAsSystemService):
         self.project_upsert_config = admin.project.upsert_config
 
 
+class WriteAsCapResolveService(WriteAsSystemService):
+    def __init__(self, write: Write, data: db.Session):
+        super().__init__(write, data)
+        admin = WriteAsFoundationAdmin(write, data)
+        self.project_record_approval_outcome = admin.project.record_approval_outcome
+
+
 class WriteAsDistCatalogService(WriteAsSystemService):
     def __init__(self, write: Write, data: db.Session):
         super().__init__(write, data)
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index 36202007..00a987d8 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -24,6 +24,7 @@ from typing import TYPE_CHECKING
 
 import sqlalchemy.exc
 
+import atr.constants as constants
 import atr.cycles as cycles
 import atr.db as db
 import atr.models.api as api
@@ -32,6 +33,8 @@ import atr.models.sql as sql
 import atr.models.validation as validation
 import atr.registry as registry
 import atr.storage as storage
+import atr.tasks as tasks
+import atr.util as util
 
 if TYPE_CHECKING:
     import atr.shared as shared
@@ -232,10 +235,11 @@ class CommitteeMember(ReleaseManager):
             project_key=str(project_key),
         )
 
-    async def archive(self, project_key: safe.ProjectKey) -> None:
+    async def archive(self, project_key: safe.ProjectKey, approval_request_id: int) -> None:
         await self.__data.begin_immediate()
         self.__data.expire_all()
         try:
+            await self.__claim_approval(approval_request_id, project_key, sql.ApprovalAction.ARCHIVE)
             project = await self.__data.project(
                 key=str(project_key), status=sql.ProjectStatus.ACTIVE, _releases=True
             ).get()
@@ -256,6 +260,12 @@ class CommitteeMember(ReleaseManager):
                     " complete or remove them first."
                 )
 
+            if project.releases_including_embargoed:
+                raise storage.AccessError(
+                    f"Cannot archive project '{project_key}' because it still has draft releases;"
+                    " delete them and try again."
+                )
+
             if project.committee_key:
                 committee_projects = await self.__data.project(
                     committee_key=project.committee_key, status=sql.ProjectStatus.ACTIVE
@@ -273,12 +283,14 @@ class CommitteeMember(ReleaseManager):
         self.__write_as.append_to_audit_log(
             asf_uid=self.__asf_uid,
             project_key=str(project_key),
+            approval_request_id=approval_request_id,
         )
 
-    async def delete(self, project_key: safe.ProjectKey) -> None:
+    async def delete(self, project_key: safe.ProjectKey, approval_request_id: int) -> None:
         await self.__data.begin_immediate()
         self.__data.expire_all()
         try:
+            await self.__claim_approval(approval_request_id, project_key, sql.ApprovalAction.DELETE)
             project = await self.__data.project(
                 key=str(project_key), status=sql.ProjectStatus.ACTIVE, _releases=True
             ).get()
@@ -308,6 +320,7 @@ class CommitteeMember(ReleaseManager):
         self.__write_as.append_to_audit_log(
             asf_uid=self.__asf_uid,
             project_key=str(project_key),
+            approval_request_id=approval_request_id,
         )
         return None
 
@@ -355,6 +368,62 @@ class CommitteeMember(ReleaseManager):
             return True
         return False
 
+    async def request_approval(
+        self,
+        project_key: safe.ProjectKey,
+        action: sql.ApprovalAction,
+        cap_question_id: int,
+        closes_at: datetime.datetime,
+    ) -> sql.ApprovalRequest:
+        approval = sql.ApprovalRequest(
+            project_key=str(project_key),
+            committee_key=self.__committee_key,
+            action=action,
+            cap_question_id=cap_question_id,
+            requested_by=self.__asf_uid,
+            closes_at=closes_at,
+        )
+        try:
+            self.__data.add(approval)
+            await self.__data.flush()
+            await tasks.cap_approval_resolve(
+                util.unwrap(approval.id),
+                schedule=closes_at + constants.CAP_RESOLVE_INITIAL_DELAY,
+                caller_data=self.__data,
+            )
+            await self.__data.commit()
+        except sqlalchemy.exc.IntegrityError as error:
+            await self.__data.rollback()
+            if "approvalrequest.project_key" in str(error):
+                raise storage.AccessError("A CAP approval request for this project is already in progress.", status=409)
+            raise storage.AccessError(
+                f"CAP issued question #{cap_question_id}, which ATR has already recorded for another request.",
+                status=409,
+            )
+        except Exception:
+            await self.__data.rollback()
+            raise
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            project_key=str(project_key),
+            approval_action=action.value,
+            cap_question_id=cap_question_id,
+            closes_at=closes_at.isoformat(),
+        )
+        return approval
+
+    async def __claim_approval(
+        self, approval_request_id: int, project_key: safe.ProjectKey, action: sql.ApprovalAction
+    ) -> None:
+        approval = await self.__data.approval_request(id=approval_request_id).get()
+        if (approval is None) or (approval.status != sql.ApprovalStatus.APPROVED):
+            raise storage.AccessError("This approval request is not ready to complete.", status=409)
+        if (approval.project_key != str(project_key)) or (approval.action != action):
+            raise storage.AccessError("This approval request does not match the requested action.", status=409)
+        if approval.committee_key != self.__committee_key:
+            raise storage.AccessError("This approval request was filed for a different committee.", status=409)
+        approval.status = sql.ApprovalStatus.COMPLETED
+
     def __current_categories(self, project: sql.Project) -> list[str]:
         return (
             [category.strip() for category in (project.categories or "").split(",") if category.strip()]
@@ -382,6 +451,40 @@ class FoundationAdmin(FoundationCommitter):
             raise storage.AccessError("Not authorized", status=403)
         self.__asf_uid = asf_uid
 
+    async def record_approval_outcome(
+        self,
+        approval_request_id: int,
+        status: sql.ApprovalStatus,
+        vote_outcome: str | None,
+        permalink: str | None,
+        error: str | None = None,
+    ) -> bool:
+        await self.__data.begin_immediate()
+        self.__data.expire_all()
+        try:
+            approval = await self.__data.approval_request(id=approval_request_id).get()
+            if (approval is None) or (approval.status != sql.ApprovalStatus.PENDING):
+                await self.__data.rollback()
+                return False
+            approval.status = status
+            approval.outcome = vote_outcome
+            approval.permalink = permalink
+            approval.error = error
+            if error is None:
+                approval.resolved_at = datetime.datetime.now(datetime.UTC)
+            await self.__data.commit()
+        except Exception:
+            await self.__data.rollback()
+            raise
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            approval_request_id=approval_request_id,
+            approval_status=status.value,
+            outcome=vote_outcome,
+            error=error,
+        )
+        return True
+
     async def upsert_config(
         self,
         args: api.ProjectConfigArgs,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index e5399872..ed7e10e0 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -305,6 +305,7 @@ async def _start_release(
     expedited: bool,
 ) -> tuple[sql.Release, sql.Project]:
     """Creates the initial release draft record and revision directory."""
+    await data.begin_immediate()
     project = await data.project(key=str(project_key), status=sql.ProjectStatus.ACTIVE, _committee=True).get()
     if not project:
         raise storage.AccessError(f"Project {project_key} not found", status=404)
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index b805f6cb..f2e6ab95 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -26,6 +26,7 @@ import sqlmodel
 
 import atr.analysis as analysis
 import atr.attestable as attestable
+import atr.constants as constants
 import atr.db as db
 import atr.hashes as hashes
 import atr.log as log
@@ -34,6 +35,7 @@ import atr.models.results as results
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.paths as file_paths
+import atr.tasks.cap as cap
 import atr.tasks.checks as checks
 import atr.tasks.checks.compare as compare
 import atr.tasks.checks.hashing as hashing
@@ -91,6 +93,33 @@ async def asc_checks(
     return tasks
 
 
+async def cap_approval_resolve(
+    approval_request_id: int,
+    *,
+    schedule: datetime.datetime,
+    attempt: int = 0,
+    caller_data: db.Session | None = None,
+) -> sql.Task:
+    task_args = args.CapApprovalResolveArgs(approval_request_id=approval_request_id, attempt=attempt)
+    async with db.ensure_session(caller_data) as data:
+        task = sql.Task(
+            status=sql.TaskStatus.QUEUED,
+            task_type=sql.TaskType.CAP_APPROVAL_RESOLVE,
+            task_args=task_args.model_dump(),
+            asf_uid=constants.SYSTEM_SERVICE_UID,
+            revision_number=None,
+            primary_rel_path=None,
+            project_key=None,
+            version_key=None,
+        )
+        task.scheduled = schedule
+        data.add(task)
+        await data.flush()
+        if caller_data is None:
+            await data.commit()
+        return task
+
+
 async def clear_scheduled(caller_data: db.Session | None = None) -> None:
     """Clear all future scheduled tasks of the given types."""
     async with db.ensure_session(caller_data) as data:
@@ -316,6 +345,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
     match task_type:
         case sql.TaskType.ARCHIVE_COMPARISON:
             return parity.across_formats
+        case sql.TaskType.CAP_APPROVAL_RESOLVE:
+            return cap.resolve
         case sql.TaskType.COMPARE_SOURCE_TREES:
             return compare.source_trees
         case sql.TaskType.DISTRIBUTION_STATUS:
diff --git a/atr/tasks/cap.py b/atr/tasks/cap.py
new file mode 100644
index 00000000..1c1f7921
--- /dev/null
+++ b/atr/tasks/cap.py
@@ -0,0 +1,123 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+
+import atr.constants as constants
+import atr.db as db
+import atr.log as log
+import atr.models.args as args
+import atr.models.cap
+import atr.models.results as results
+import atr.models.sql as sql
+import atr.storage as storage
+import atr.tasks as tasks
+import atr.tasks.checks as checks
+import atr.util as util
+
+
+async def notify(asf_uid: str, message: str, level: sql.NotificationLevel) -> None:
+    if asf_uid == constants.SYSTEM_SERVICE_UID:
+        return
+    try:
+        async with storage.write_as_user_service(asf_uid) as waus:
+            await waus.notifications_create(message, level)
+    except Exception:
+        log.exception("Failed to record CAP notification")
+
+
+@checks.with_model(args.CapApprovalResolveArgs)
+async def resolve(task_args: args.CapApprovalResolveArgs, *, task_id: int | None = None) -> results.Results | None:
+    result = results.CapApprovalResolve(kind="cap_approval_resolve")
+    async with db.session() as data:
+        row = await data.approval_request(id=task_args.approval_request_id).get()
+    if row is None:
+        log.error(f"CAP approval request {task_args.approval_request_id} not found")
+        return result
+    if row.status != sql.ApprovalStatus.PENDING:
+        return result
+
+    try:
+        resolution = await _resolve_question(row.cap_question_id)
+        if (resolution is not None) and (resolution.outcome is not None):
+            await _apply_outcome(task_args.approval_request_id, row, resolution)
+            return result
+        if resolution is not None:
+            log.warning(f"CAP resolution for question {row.cap_question_id} returned no outcome")
+    except Exception:
+        log.exception(f"CAP approval resolve failed for request {task_args.approval_request_id}")
+
+    await _reschedule_or_fail(task_args, row)
+    return result
+
+
+async def _apply_outcome(request_id: int, row: sql.ApprovalRequest, resolution: atr.models.cap.Resolution) -> None:
+    vote_outcome = resolution.outcome
+    permalink = resolution.permalink
+    if vote_outcome == "approved":
+        if await _record_outcome(request_id, sql.ApprovalStatus.APPROVED, vote_outcome, permalink):
+            await notify(
+                row.requested_by,
+                f"The CAP approval vote to {row.action.value} project {row.project_key} passed. "
+                f"You can now complete the {row.action.value}.",
+                sql.NotificationLevel.INFO,
+            )
+        return
+    if await _record_outcome(request_id, sql.ApprovalStatus.REJECTED, vote_outcome, permalink):
+        await notify(
+            row.requested_by,
+            f"The CAP approval vote to {row.action.value} project {row.project_key} did not pass "
+            f"(outcome: {vote_outcome}).",
+            sql.NotificationLevel.WARNING,
+        )
+
+
+async def _record_outcome(
+    request_id: int,
+    status: sql.ApprovalStatus,
+    vote_outcome: str | None,
+    permalink: str | None,
+    *,
+    error: str | None = None,
+) -> bool:
+    async with storage.write_as_system(storage.WriteAsCapResolveService) as wacrs:
+        return await wacrs.project_record_approval_outcome(request_id, status, vote_outcome, permalink, error=error)
+
+
+async def _reschedule_or_fail(task_args: args.CapApprovalResolveArgs, row: sql.ApprovalRequest) -> None:
+    next_attempt = task_args.attempt + 1
+    if next_attempt <= len(constants.CAP_RESOLVE_RETRY_DELAYS):
+        schedule = datetime.datetime.now(datetime.UTC) + constants.CAP_RESOLVE_RETRY_DELAYS[next_attempt - 1]
+        await tasks.cap_approval_resolve(task_args.approval_request_id, attempt=next_attempt, schedule=schedule)
+        return
+    attempts = len(constants.CAP_RESOLVE_RETRY_DELAYS) + 1
+    error = f"CAP could not be resolved after {attempts} attempts"
+    if await _record_outcome(task_args.approval_request_id, sql.ApprovalStatus.FAILED, None, None, error=error):
+        await notify(
+            row.requested_by,
+            f"ATR could not resolve the CAP approval vote to {row.action.value} project {row.project_key}: {error}.",
+            sql.NotificationLevel.ERROR,
+        )
+
+
+async def _resolve_question(question_id: int) -> atr.models.cap.Resolution | None:
+    try:
+        token = await util.cap_mint_token()
+        return await util.cap_resolve_question(token, question_id)
+    except util.FetchError as error:
+        log.warning(f"CAP resolve unavailable for question {question_id}: {error}")
+        return None
diff --git a/atr/util.py b/atr/util.py
index 8e0bb266..2346062c 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -47,6 +47,7 @@ import asfquart.session as session
 import gitignore_parser
 import jinja2
 import markupsafe
+import pydantic
 import quart
 
 # NOTE: The atr.db module imports this module
@@ -55,6 +56,7 @@ import atr.config as config
 import atr.constants as constants
 import atr.ldap as ldap
 import atr.log as log
+import atr.models.cap as cap
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.models.validation as validation
@@ -105,6 +107,7 @@ PRIVATE_KEY_UPLOAD_WARNING: Final[str] = (
 )
 USER_TESTS_ADDRESS: Final[str] = "user-tests@tooling.apache.org"
 LISTS_APACHE_TIMEOUT: Final[aiohttp.ClientTimeout] = aiohttp.ClientTimeout(total=30, connect=10)
+CAP_TIMEOUT: Final[aiohttp.ClientTimeout] = aiohttp.ClientTimeout(total=30, connect=10)
 MAX_PROPAGATION_ARTIFACTS: Final[int] = 24
 EXPECTED_DEFAULT_TLS_CHECK_HOSTNAME: Final[bool] = True
 EXPECTED_DEFAULT_TLS_MINIMUM_VERSION: Final[ssl.TLSVersion] = ssl.TLSVersion.TLSv1_2
@@ -316,6 +319,90 @@ async def atomic_write_file(file_path: pathlib.Path, content: str, encoding: str
         raise
 
 
+async def cap_create_question(
+    token: str,
+    *,
+    project_id: str,
+    title: str,
+    description: str,
+    target_audience: str,
+    approval_type: str,
+    closes_at: datetime.datetime,
+) -> cap.Question:
+    url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/api/question"
+    headers = {"Authorization": f"bearer {token}"}
+    payload = {
+        "project_id": project_id,
+        "title": title,
+        "description": description,
+        "target_audience": target_audience,
+        "approval_type": approval_type,
+        "is_binding": True,
+        "is_private": True,
+        "response_option": {"kind": "vote"},
+        "closes_at": closes_at.astimezone(datetime.UTC).isoformat(),
+    }
+    try:
+        async with create_secure_session(timeout=CAP_TIMEOUT) as http_session:
+            async with http_session.post(url, json=payload, headers=headers) as response:
+                status = response.status
+                body = await response.text()
+    except Exception as exc:
+        raise FetchError(f"CAP question creation failed: {exc}", url=url) from exc
+    if status != 201:
+        raise FetchError(f"CAP question creation failed ({status}): {body}", url=url)
+    try:
+        return cap.Question.model_validate_json(body)
+    except pydantic.ValidationError as exc:
+        raise FetchError(f"CAP question creation returned an invalid response: {exc}", url=url) from exc
+
+
+async def cap_mint_token() -> str:
+    url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/api/token"
+    permanent = config.get().CAP_ROLE_ACCOUNT_TOKEN
+    if not permanent:
+        raise FetchError("CAP role account token is not configured", url=url)
+    headers = {"Authorization": f"bearer {permanent}"}
+    try:
+        async with create_secure_session(timeout=CAP_TIMEOUT) as http_session:
+            async with http_session.get(url, headers=headers) as response:
+                status = response.status
+                body = await response.text()
+    except Exception as exc:
+        raise FetchError(f"CAP token minting failed: {exc}", url=url) from exc
+    if status != 201:
+        raise FetchError(f"CAP token minting failed ({status})", url=url)
+    try:
+        return cap.TokenIssued.model_validate_json(body).token
+    except pydantic.ValidationError as exc:
+        raise FetchError(f"CAP token minting returned an invalid response: {exc}", url=url) from exc
+
+
+async def cap_resolve_question(token: str, question_id: int) -> cap.Resolution | None:
+    url = f"{config.get().CAP_API_BASE_URL.rstrip('/')}/api/question/{question_id}/resolve"
+    headers = {"Authorization": f"bearer {token}"}
+    try:
+        async with create_secure_session(timeout=CAP_TIMEOUT) as http_session:
+            async with http_session.post(url, headers=headers) as response:
+                status = response.status
+                body = await response.text()
+    except Exception as exc:
+        raise FetchError(f"CAP question resolution failed: {exc}", url=url) from exc
+    if status == 403:
+        try:
+            error_kind = cap.ErrorMessage.model_validate_json(body).error
+        except pydantic.ValidationError:
+            error_kind = None
+        if error_kind == "deadline_in_future":
+            return None
+    if status != 200:
+        raise FetchError(f"CAP question resolution failed ({status}): {body}", url=url)
+    try:
+        return cap.Resolution.model_validate_json(body)
+    except pydantic.ValidationError as exc:
+        raise FetchError(f"CAP question resolution returned an invalid response: {exc}", url=url) from exc
+
+
 async def check_propagation(
     target: SvnPublishTarget,
     public_base_url: str,
diff --git a/migrations/versions/0102_2026.06.30_258b186f.py b/migrations/versions/0102_2026.06.30_258b186f.py
new file mode 100644
index 00000000..b5930564
--- /dev/null
+++ b/migrations/versions/0102_2026.06.30_258b186f.py
@@ -0,0 +1,75 @@
+# 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.
+
+"""Add approvalrequest table for CAP gated archival and deletion
+
+Revision ID: 0102_2026.06.30_258b186f
+Revises: 0101_2026.06.30_ee33c8d8
+Create Date: 2026-06-30 17:59:31.689612+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+import atr.models.sql as sql
+
+revision: str = "0102_2026.06.30_258b186f"
+down_revision: str | None = "0101_2026.06.30_ee33c8d8"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "approvalrequest",
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("project_key", sa.String(), nullable=False),
+        sa.Column("committee_key", sa.String(), nullable=False),
+        sa.Column("action", sa.Enum("ARCHIVE", "DELETE", name="approvalaction"), nullable=False),
+        sa.Column("cap_question_id", sa.Integer(), nullable=False),
+        sa.Column(
+            "status",
+            sa.Enum("PENDING", "APPROVED", "REJECTED", "COMPLETED", "FAILED", name="approvalstatus"),
+            nullable=False,
+        ),
+        sa.Column("outcome", sa.String(), nullable=True),
+        sa.Column("requested_by", sa.String(), nullable=False),
+        sa.Column("requested_at", sql.UTCDateTime(timezone=True), nullable=False),
+        sa.Column("closes_at", sql.UTCDateTime(timezone=True), nullable=False),
+        sa.Column("resolved_at", sql.UTCDateTime(timezone=True), nullable=True),
+        sa.Column("permalink", sa.String(), nullable=True),
+        sa.Column("error", sa.String(), nullable=True),
+        sa.PrimaryKeyConstraint("id", name=op.f("pk_approvalrequest")),
+        sa.UniqueConstraint("cap_question_id", name=op.f("uq_approvalrequest_cap_question_id")),
+    )
+    with op.batch_alter_table("approvalrequest", schema=None) as batch_op:
+        batch_op.create_index(batch_op.f("ix_approvalrequest_status"), ["status"], unique=False)
+        batch_op.create_index(
+            "ix_approvalrequest_active_project",
+            ["project_key"],
+            unique=True,
+            sqlite_where=sa.text("status IN ('PENDING', 'APPROVED')"),
+        )
+
+
+def downgrade() -> None:
+    with op.batch_alter_table("approvalrequest", schema=None) as batch_op:
+        batch_op.drop_index("ix_approvalrequest_active_project")
+        batch_op.drop_index(batch_op.f("ix_approvalrequest_status"))
+    op.drop_table("approvalrequest")
diff --git a/tests/unit/test_cap.py b/tests/unit/test_cap.py
new file mode 100644
index 00000000..9fe83364
--- /dev/null
+++ b/tests/unit/test_cap.py
@@ -0,0 +1,458 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import json
+import unittest.mock as mock
+
+import pytest
+import sqlalchemy.exc
+
+import atr.models.cap
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.post.projects as projects
+import atr.storage as storage
+import atr.storage.writers.project as project
+import atr.tasks.cap as cap
+import atr.util as util
+
+
+class Conf:
+    CAP_API_BASE_URL = "https://cap.example.test"
+    CAP_ROLE_ACCOUNT_TOKEN = "perm-token"
+
+
+class FakeResponse:
+    def __init__(self, status: int, body: str):
+        self.status = status
+        self._body = body
+
+    async def text(self) -> str:
+        return self._body
+
+    async def __aenter__(self) -> "FakeResponse":
+        return self
+
+    async def __aexit__(self, *args: object) -> bool:
+        return False
+
+
+class FakeSession:
+    def __init__(self, response: FakeResponse):
+        self._response = response
+
+    def get(self, url: str, **kwargs: object) -> FakeResponse:
+        return self._response
+
+    def post(self, url: str, **kwargs: object) -> FakeResponse:
+        return self._response
+
+    async def __aenter__(self) -> "FakeSession":
+        return self
+
+    async def __aexit__(self, *args: object) -> bool:
+        return False
+
+
+class FakeQuery:
+    def __init__(self, row: sql.ApprovalRequest | None):
+        self._row = row
+
+    async def get(self, log_query: bool = False) -> sql.ApprovalRequest | None:
+        return self._row
+
+
+class FakeData:
+    def __init__(self, row: sql.ApprovalRequest | None):
+        self.row = row
+
+    def approval_request(self, **kwargs: object) -> FakeQuery:
+        return FakeQuery(self.row)
+
+    async def begin_immediate(self) -> None:
+        return None
+
+    def add(self, obj: object) -> None:
+        if isinstance(obj, sql.ApprovalRequest) and obj.id is None:
+            obj.id = 1
+        return None
+
+    async def commit(self) -> None:
+        return None
+
+    def expire_all(self) -> None:
+        return None
+
+    async def flush(self) -> None:
+        return None
+
+    async def rollback(self) -> None:
+        return None
+
+
+class FakeSessionCM:
+    def __init__(self, data: FakeData):
+        self._data = data
+
+    async def __aenter__(self) -> FakeData:
+        return self._data
+
+    async def __aexit__(self, *args: object) -> bool:
+        return False
+
+
+class FakeSystemService:
+    def __init__(self, record: object):
+        self.project_record_approval_outcome = record
+
+    async def __aenter__(self) -> "FakeSystemService":
+        return self
+
+    async def __aexit__(self, *args: object) -> bool:
+        return False
+
+
+class FakeUserService:
+    def __init__(self) -> None:
+        self.notifications_create = mock.AsyncMock()
+
+    async def __aenter__(self) -> "FakeUserService":
+        return self
+
+    async def __aexit__(self, *args: object) -> bool:
+        return False
+
+
+class FakeAuthorisation:
+    asf_uid = "alice"
+
+
+class FakeWrite:
+    authorisation = FakeAuthorisation()
+
+
+class FakeWriteAs:
+    def append_to_audit_log(self, **kwargs: object) -> None:
+        return None
+
+
+@pytest.fixture
+def cap_config(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr(util.config, "get", lambda: Conf)
+
+
+@pytest.mark.asyncio
+async def test_cap_create_question_error(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 500, "boom")
+    with pytest.raises(util.FetchError):
+        await util.cap_create_question(
+            "tok",
+            project_id="commons",
+            title="t",
+            description="d",
+            target_audience="a",
+            approval_type="majority_approval",
+            closes_at=datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC),
+        )
+
+
+@pytest.mark.asyncio
+async def test_cap_create_question_invalid_json(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 201, "<html>not json</html>")
+    with pytest.raises(util.FetchError):
+        await util.cap_create_question(
+            "tok",
+            project_id="commons",
+            title="t",
+            description="d",
+            target_audience="a",
+            approval_type="majority_approval",
+            closes_at=datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC),
+        )
+
+
+@pytest.mark.asyncio
+async def test_cap_create_question_success(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 201, json.dumps({"question_id": 42, "permalink": None}))
+    closes_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+    result = await util.cap_create_question(
+        "tok",
+        project_id="commons",
+        title="t",
+        description="d",
+        target_audience="a",
+        approval_type="majority_approval",
+        closes_at=closes_at,
+    )
+    assert result.question_id == 42
+
+
+@pytest.mark.asyncio
+async def test_cap_mint_token_success(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 201, json.dumps({"token": "temp-token", "scopes": ["ask"]}))
+    assert await util.cap_mint_token() == "temp-token"
+
+
+@pytest.mark.asyncio
+async def test_cap_mint_token_unconfigured(monkeypatch: pytest.MonkeyPatch) -> None:
+    class NoToken:
+        CAP_API_BASE_URL = "https://cap.example.test"
+        CAP_ROLE_ACCOUNT_TOKEN = None
+
+    monkeypatch.setattr(util.config, "get", lambda: NoToken)
+    with pytest.raises(util.FetchError):
+        await util.cap_mint_token()
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_approved(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 200, json.dumps({"outcome": "approved", "permalink": "https://cap/x"}))
+    resolution = await util.cap_resolve_question("tok", 42)
+    assert resolution is not None
+    assert resolution.outcome == "approved"
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_deadline_in_future(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 403, json.dumps({"error": "deadline_in_future"}))
+    assert await util.cap_resolve_question("tok", 42) is None
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_error(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 500, "boom")
+    with pytest.raises(util.FetchError):
+        await util.cap_resolve_question("tok", 42)
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_invalid_json(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 200, "<html>not json</html>")
+    with pytest.raises(util.FetchError):
+        await util.cap_resolve_question("tok", 42)
+
+
+@pytest.mark.asyncio
+async def test_cap_resolve_question_not_approved(monkeypatch: pytest.MonkeyPatch, cap_config: None) -> None:
+    _patch_http(monkeypatch, 200, json.dumps({"outcome": "insufficient_votes"}))
+    resolution = await util.cap_resolve_question("tok", 42)
+    assert resolution is not None
+    assert resolution.outcome == "insufficient_votes"
+
+
+def test_completion_eligibility_allows_empty_project() -> None:
+    requested_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+    project = _project_with_drafts()
+    error = projects._action_eligibility_error(
+        project, sql.ApprovalAction.ARCHIVE, 2, completing=True, requested_at=requested_at
+    )
+    assert error is None
+
+
+def test_completion_eligibility_allows_prior_drafts() -> None:
+    requested_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+    project = _project_with_drafts(requested_at - datetime.timedelta(days=1))
+    error = projects._action_eligibility_error(
+        project, sql.ApprovalAction.ARCHIVE, 2, completing=True, requested_at=requested_at
+    )
+    assert error is None
+
+
+def test_completion_eligibility_blocks_new_drafts() -> None:
+    requested_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+    project = _project_with_drafts(requested_at + datetime.timedelta(hours=1))
+    error = projects._action_eligibility_error(
+        project, sql.ApprovalAction.ARCHIVE, 2, completing=True, requested_at=requested_at
+    )
+    assert error is not None
+    assert "created after" in error
+
+
+@pytest.mark.asyncio
+async def test_request_approval_question_id_collision(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(None)
+    data.commit = mock.AsyncMock(
+        side_effect=sqlalchemy.exc.IntegrityError(
+            "INSERT", {}, Exception("UNIQUE constraint failed: approvalrequest.cap_question_id")
+        )
+    )
+    wacm = project.CommitteeMember(FakeWrite(), FakeWriteAs(), data, "commons")
+    monkeypatch.setattr(project.tasks, "cap_approval_resolve", mock.AsyncMock())
+    closes_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+    with pytest.raises(storage.AccessError, match="already recorded"):
+        await wacm.request_approval(safe.ProjectKey("commons-foo"), sql.ApprovalAction.ARCHIVE, 42, closes_at)
+
+
+@pytest.mark.asyncio
+async def test_request_approval_schedules_initial_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(None)
+    wacm = project.CommitteeMember(FakeWrite(), FakeWriteAs(), data, "commons")
+    schedule = mock.AsyncMock()
+    monkeypatch.setattr(project.tasks, "cap_approval_resolve", schedule)
+    closes_at = datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC)
+    approval = await wacm.request_approval(safe.ProjectKey("commons-foo"), sql.ApprovalAction.ARCHIVE, 42, closes_at)
+    assert approval.status == sql.ApprovalStatus.PENDING
+    assert approval.requested_by == "alice"
+    schedule.assert_awaited_once()
+    assert schedule.call_args.kwargs["schedule"] == closes_at + projects.constants.CAP_RESOLVE_INITIAL_DELAY
+    assert schedule.call_args.kwargs["caller_data"] is data
+
+
+@pytest.mark.asyncio
+async def test_resolve_approved_marks_approved(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    reenqueue = _patch_resolve_env(monkeypatch, data, {"outcome": "approved", "permalink": "https://cap/x"})
+    result = await cap.resolve({"approval_request_id": 1, "attempt": 0})
+    assert isinstance(result, results.CapApprovalResolve)
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.APPROVED
+    assert data.row.permalink == "https://cap/x"
+    reenqueue.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_resolve_db_error_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    reenqueue = _patch_resolve_env(monkeypatch, data, {"outcome": "approved", "permalink": "https://cap/x"})
+    data.begin_immediate = mock.AsyncMock(side_effect=sqlalchemy.exc.InvalidRequestError("database error"))
+    await cap.resolve({"approval_request_id": 1, "attempt": 0})
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.PENDING
+    reenqueue.assert_awaited_once()
+    assert reenqueue.call_args.kwargs["attempt"] == 1
+
+
+@pytest.mark.asyncio
+async def test_resolve_exhausted_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    reenqueue = _patch_resolve_env(monkeypatch, data, None)
+    await cap.resolve({"approval_request_id": 1, "attempt": len(cap.constants.CAP_RESOLVE_RETRY_DELAYS)})
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.FAILED
+    assert data.row.error is not None
+    reenqueue.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_resolve_last_attempt_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    reenqueue = _patch_resolve_env(monkeypatch, data, None)
+    await cap.resolve({"approval_request_id": 1, "attempt": len(cap.constants.CAP_RESOLVE_RETRY_DELAYS) - 1})
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.PENDING
+    reenqueue.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_resolve_missing_outcome_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    reenqueue = _patch_resolve_env(monkeypatch, data, {"permalink": "https://cap/x"})
+    await cap.resolve({"approval_request_id": 1, "attempt": 0})
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.PENDING
+    reenqueue.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_resolve_not_approved_marks_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    _patch_resolve_env(monkeypatch, data, {"outcome": "insufficient_votes", "permalink": "https://cap/x"})
+    await cap.resolve({"approval_request_id": 1, "attempt": 0})
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.REJECTED
+    assert data.row.outcome == "insufficient_votes"
+
+
+@pytest.mark.asyncio
+async def test_resolve_unavailable_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    reenqueue = _patch_resolve_env(monkeypatch, data, None)
+    before = datetime.datetime.now(datetime.UTC)
+    await cap.resolve({"approval_request_id": 1, "attempt": 0})
+    after = datetime.datetime.now(datetime.UTC)
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.PENDING
+    reenqueue.assert_awaited_once()
+    assert reenqueue.call_args.kwargs["attempt"] == 1
+    assert before + cap.constants.CAP_RESOLVE_RETRY_DELAYS[0] <= reenqueue.call_args.kwargs["schedule"]
+    assert reenqueue.call_args.kwargs["schedule"] <= after + cap.constants.CAP_RESOLVE_RETRY_DELAYS[0]
+
+
+@pytest.mark.asyncio
+async def test_resolve_unexpected_error_reenqueues(monkeypatch: pytest.MonkeyPatch) -> None:
+    data = FakeData(_approval_row())
+    reenqueue = _patch_resolve_env(monkeypatch, data, {"outcome": "approved", "permalink": "https://cap/x"})
+    monkeypatch.setattr(cap.util, "cap_resolve_question", mock.AsyncMock(side_effect=RuntimeError("unexpected")))
+    await cap.resolve({"approval_request_id": 1, "attempt": 0})
+    assert data.row is not None
+    assert data.row.status == sql.ApprovalStatus.PENDING
+    reenqueue.assert_awaited_once()
+    assert reenqueue.call_args.kwargs["attempt"] == 1
+
+
+def _approval_row(
+    status: sql.ApprovalStatus = sql.ApprovalStatus.PENDING,
+    action: sql.ApprovalAction = sql.ApprovalAction.ARCHIVE,
+) -> sql.ApprovalRequest:
+    return sql.ApprovalRequest(
+        id=1,
+        project_key="commons-foo",
+        committee_key="commons",
+        action=action,
+        cap_question_id=10,
+        status=status,
+        requested_by="alice",
+        closes_at=datetime.datetime(2026, 7, 1, tzinfo=datetime.UTC),
+    )
+
+
+def _patch_http(monkeypatch: pytest.MonkeyPatch, status: int, body: str) -> None:
+    session = FakeSession(FakeResponse(status, body))
+    monkeypatch.setattr(util, "create_secure_session", lambda **kwargs: session)
+
+
+def _patch_resolve_env(
+    monkeypatch: pytest.MonkeyPatch, data: FakeData, resolution: dict[str, object] | None
+) -> mock.AsyncMock:
+    resolution_model = None
+    if resolution is not None:
+        resolution_model = atr.models.cap.Resolution.model_validate(resolution)
+    monkeypatch.setattr(cap.db, "session", lambda: FakeSessionCM(data))
+    monkeypatch.setattr(cap.util, "cap_mint_token", mock.AsyncMock(return_value="tok"))
+    monkeypatch.setattr(cap.util, "cap_resolve_question", mock.AsyncMock(return_value=resolution_model))
+    monkeypatch.setattr(cap.storage, "write_as_user_service", lambda uid: FakeUserService())
+    admin = project.FoundationAdmin(FakeWrite(), FakeWriteAs(), data)
+    monkeypatch.setattr(cap.storage, "write_as_system", lambda cls: FakeSystemService(admin.record_approval_outcome))
+    reenqueue = mock.AsyncMock()
+    monkeypatch.setattr(cap.tasks, "cap_approval_resolve", reenqueue)
+    return reenqueue
+
+
+def _project_with_drafts(*created: datetime.datetime) -> sql.Project:
+    project = sql.Project(key="commons-foo", status=sql.ProjectStatus.ACTIVE)
+    project.releases_including_embargoed = [
+        sql.Release(
+            project_key="commons-foo",
+            version=f"0.{i}",
+            phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+            created=created_at,
+        )
+        for i, created_at in enumerate(created)
+    ]
+    return project
diff --git a/tests/unit/test_project_writer_delete.py b/tests/unit/test_project_writer_delete.py
index ae829840..9aa4a2d3 100644
--- a/tests/unit/test_project_writer_delete.py
+++ b/tests/unit/test_project_writer_delete.py
@@ -26,6 +26,25 @@ import atr.storage as storage
 import atr.storage.writers.project as project
 
 
+@pytest.mark.asyncio
+async def test_archive_committee_mismatch_rolls_back() -> None:
+    data = mock.MagicMock()
+    data.begin_immediate = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
+    data.commit = mock.AsyncMock()
+    data.approval_request = mock.MagicMock(
+        return_value=_query(get=_approval(sql.ApprovalAction.ARCHIVE, committee_key="beta"))
+    )
+    writer = _writer(data)
+
+    with pytest.raises(storage.AccessError, match="different committee"):
+        await writer.archive(safe.ProjectKey("alpha-one"), 7)
+
+    data.begin_immediate.assert_awaited_once()
+    data.rollback.assert_awaited_once()
+    data.commit.assert_not_awaited()
+
+
 @pytest.mark.asyncio
 async def test_archive_sole_active_project_locks_then_rolls_back() -> None:
     target = SimpleNamespace(
@@ -35,11 +54,35 @@ async def test_archive_sole_active_project_locks_then_rolls_back() -> None:
     data.begin_immediate = mock.AsyncMock()
     data.rollback = mock.AsyncMock()
     data.commit = mock.AsyncMock()
+    data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.ARCHIVE)))
     data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target])])
     writer = _writer(data)
 
     with pytest.raises(storage.AccessError, match="only project"):
-        await writer.archive(safe.ProjectKey("alpha-one"))
+        await writer.archive(safe.ProjectKey("alpha-one"), 7)
+
+    data.begin_immediate.assert_awaited_once()
+    data.rollback.assert_awaited_once()
+    data.commit.assert_not_awaited()
+    assert target.status == sql.ProjectStatus.ACTIVE
+
+
+@pytest.mark.asyncio
+async def test_archive_with_drafts_rolls_back() -> None:
+    draft = SimpleNamespace(phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
+    target = SimpleNamespace(
+        key="alpha-one", committee_key="alpha", status=sql.ProjectStatus.ACTIVE, releases_including_embargoed=[draft]
+    )
+    data = mock.MagicMock()
+    data.begin_immediate = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
+    data.commit = mock.AsyncMock()
+    data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.ARCHIVE)))
+    data.project = mock.MagicMock(side_effect=[_query(get=target)])
+    writer = _writer(data)
+
+    with pytest.raises(storage.AccessError, match="draft releases"):
+        await writer.archive(safe.ProjectKey("alpha-one"), 7)
 
     data.begin_immediate.assert_awaited_once()
     data.rollback.assert_awaited_once()
@@ -56,10 +99,11 @@ async def test_delete_non_sole_project_commits() -> None:
     data.rollback = mock.AsyncMock()
     data.commit = mock.AsyncMock()
     data.delete = mock.AsyncMock()
+    data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.DELETE)))
     data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target, other])])
     writer = _writer(data)
 
-    await writer.delete(safe.ProjectKey("alpha-one"))
+    await writer.delete(safe.ProjectKey("alpha-one"), 7)
 
     data.begin_immediate.assert_awaited_once()
     data.delete.assert_awaited_once_with(target)
@@ -75,11 +119,12 @@ async def test_delete_sole_active_project_locks_then_rolls_back() -> None:
     data.rollback = mock.AsyncMock()
     data.commit = mock.AsyncMock()
     data.delete = mock.AsyncMock()
+    data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.DELETE)))
     data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target])])
     writer = _writer(data)
 
     with pytest.raises(storage.AccessError, match="only project"):
-        await writer.delete(safe.ProjectKey("alpha-one"))
+        await writer.delete(safe.ProjectKey("alpha-one"), 7)
 
     data.begin_immediate.assert_awaited_once()
     data.rollback.assert_awaited_once()
@@ -96,11 +141,12 @@ async def test_delete_with_releases_rolls_back() -> None:
     data.rollback = mock.AsyncMock()
     data.commit = mock.AsyncMock()
     data.delete = mock.AsyncMock()
+    data.approval_request = mock.MagicMock(return_value=_query(get=_approval(sql.ApprovalAction.DELETE)))
     data.project = mock.MagicMock(side_effect=[_query(get=target), _query(all_=[target, other])])
     writer = _writer(data)
 
     with pytest.raises(storage.AccessError, match="associated releases"):
-        await writer.delete(safe.ProjectKey("alpha-one"))
+        await writer.delete(safe.ProjectKey("alpha-one"), 7)
 
     data.begin_immediate.assert_awaited_once()
     data.rollback.assert_awaited_once()
@@ -108,6 +154,12 @@ async def test_delete_with_releases_rolls_back() -> None:
     data.delete.assert_not_awaited()
 
 
+def _approval(action: sql.ApprovalAction, committee_key: str = "alpha") -> SimpleNamespace:
+    return SimpleNamespace(
+        status=sql.ApprovalStatus.APPROVED, project_key="alpha-one", committee_key=committee_key, action=action
+    )
+
+
 def _query(get: object = None, all_: list | None = None) -> mock.MagicMock:
     query = mock.MagicMock()
     query.get = mock.AsyncMock(return_value=get)
@@ -120,4 +172,5 @@ def _writer(data: mock.MagicMock) -> project.CommitteeMember:
     writer._CommitteeMember__data = data
     writer._CommitteeMember__asf_uid = "tester"
     writer._CommitteeMember__write_as = mock.MagicMock()
+    writer._CommitteeMember__committee_key = "alpha"
     return writer


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