From 8c4c4456c625d47ad5294d0c27c934cd36c3dce6 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 03:31:05 +0200 Subject: [PATCH] feat(core): define auditable poll response retirement --- src/govoplan_core/core/poll.py | 58 ++++++++++++++++++++++++++++++++++ tests/test_poll_contract.py | 50 ++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/govoplan_core/core/poll.py b/src/govoplan_core/core/poll.py index 0364b46..9f9532a 100644 --- a/src/govoplan_core/core/poll.py +++ b/src/govoplan_core/core/poll.py @@ -110,6 +110,22 @@ class PollSubmitResponseCommand: metadata: Mapping[str, object] = field(default_factory=dict) +@dataclass(frozen=True, slots=True) +class PollResponseRetirementCommand: + """Retire live responses while preserving their auditable history. + + Owning modules provide every server-trusted identity that can refer to the + participant. Providers must soft-delete matching live responses, retain + their answers, and treat an exact ``idempotency_key`` replay as a no-op. + """ + + respondent_ids: tuple[str, ...] = () + invitation_id: str | None = None + reason: str = "participant_removed" + idempotency_key: str = "" + metadata: Mapping[str, object] = field(default_factory=dict) + + @dataclass(frozen=True, slots=True) class PollOptionRef: id: str @@ -150,6 +166,16 @@ class PollResponseRef: answers: tuple[PollAnswerRef, ...] = () +@dataclass(frozen=True, slots=True) +class PollResponseRetirementRef: + """Outcome of an auditable response-retirement request.""" + + response_ids: tuple[str, ...] = () + retired_at: datetime | None = None + newly_retired_count: int = 0 + replayed: bool = False + + @runtime_checkable class PollSchedulingProvider(Protocol): def create_poll( @@ -275,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol): ... +@runtime_checkable +class PollResponseRetirementProvider(Protocol): + """Optional extension for owning modules that remove participants.""" + + def retire_responses( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + command: PollResponseRetirementCommand, + ) -> PollResponseRetirementRef: + ... + + def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None: if registry is None or not hasattr(registry, "has_capability"): return None @@ -289,3 +330,20 @@ def poll_response_submission_provider( ) -> PollResponseSubmissionProvider | None: provider = poll_scheduling_provider(registry) return provider if isinstance(provider, PollResponseSubmissionProvider) else None + + +def poll_response_retirement_provider( + registry: object | None, +) -> PollResponseRetirementProvider | None: + """Resolve response retirement without making it mandatory for Poll v1 providers.""" + + if registry is None or not hasattr(registry, "has_capability"): + return None + if not registry.has_capability(CAPABILITY_POLL_SCHEDULING): + return None + capability = registry.capability(CAPABILITY_POLL_SCHEDULING) + return ( + capability + if isinstance(capability, PollResponseRetirementProvider) + else None + ) diff --git a/tests/test_poll_contract.py b/tests/test_poll_contract.py index af7fe49..d8e8b24 100644 --- a/tests/test_poll_contract.py +++ b/tests/test_poll_contract.py @@ -3,7 +3,36 @@ from __future__ import annotations import unittest from datetime import datetime, timezone -from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef +from govoplan_core.core.poll import ( + CAPABILITY_POLL_SCHEDULING, + PollAnswerRef, + PollOptionUpdateCommand, + PollResponseRef, + PollResponseRetirementCommand, + PollResponseRetirementProvider, + poll_response_retirement_provider, +) + + +class _RetirementProvider: + def retire_responses(self, *args, **kwargs): + raise NotImplementedError + + +class _Registry: + def __init__(self, capability: object | None) -> None: + self.capability_value = capability + + def has_capability(self, name: str) -> bool: + return ( + name == CAPABILITY_POLL_SCHEDULING + and self.capability_value is not None + ) + + def capability(self, name: str) -> object: + if not self.has_capability(name): + raise KeyError(name) + return self.capability_value class PollContractTests(unittest.TestCase): @@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase): self.assertEqual(command.label, "Tuesday 10:00") self.assertEqual(command.value, {"slot_id": "slot-1"}) + def test_response_retirement_is_an_optional_idempotent_extension(self) -> None: + command = PollResponseRetirementCommand( + respondent_ids=("account-1", "alice@example.test"), + invitation_id="invitation-1", + reason="scheduling_participant_removed", + idempotency_key="scheduling:request-1:participant-1:removed", + metadata={"source_module": "scheduling"}, + ) + provider = _RetirementProvider() + + self.assertEqual(command.respondent_ids[0], "account-1") + self.assertIsInstance(provider, PollResponseRetirementProvider) + self.assertIs( + poll_response_retirement_provider(_Registry(provider)), + provider, + ) + self.assertIsNone(poll_response_retirement_provider(_Registry(object()))) + self.assertIsNone(poll_response_retirement_provider(None)) + if __name__ == "__main__": unittest.main()