diff --git a/packages/candidate-document-disposition/pyproject.toml b/packages/candidate-document-disposition/pyproject.toml new file mode 100644 index 000000000..a7ed404ae --- /dev/null +++ b/packages/candidate-document-disposition/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "orgmetra-candidate-document-disposition" +version = "0.1.0" +description = "PII-minimized candidate-document disposition packet contracts for Orgmetra." +requires-python = ">=3.12" + +[project.optional-dependencies] +test = ["pytest>=8.3", "pytest-cov>=5.0"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = [ + "--cov=orgmetra_candidate_document_disposition", + "--cov-branch", + "--cov-report=term-missing", + "--cov-fail-under=100", +] diff --git a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/__init__.py b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/__init__.py new file mode 100644 index 000000000..a8e238de8 --- /dev/null +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/__init__.py @@ -0,0 +1,9 @@ +from orgmetra_candidate_document_disposition.packet import ( + CandidateDocumentDisposition, + build_candidate_document_disposition, +) + +__all__ = [ + "CandidateDocumentDisposition", + "build_candidate_document_disposition", +] diff --git a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py new file mode 100644 index 000000000..8c0a24bd5 --- /dev/null +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -0,0 +1,504 @@ +import json +import re +from collections import namedtuple +from datetime import datetime, timezone +from hashlib import sha256 +from uuid import UUID + +_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") +_DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_REFERENCE_PATTERN = re.compile( + r"^[a-z][a-z0-9_]{1,31}:[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$" +) +_DISPOSITION_PURPOSE = "candidate_document_disposition" +_ALLOWED_REASON_CODES = frozenset({ + "non_selected_applicant_return", + "talent_pool_consent_withdrawal", + "talent_pool_expiry", + "confirmed_hire_materialization", + "statutory_retention_expiry", + "legal_hold_release", +}) +_ALLOWED_RETURN_ELIGIBILITY_CODES = frozenset({ + "eligible", + "return_exception_voluntary_submission", + "return_exception_not_requested_by_employer", + "waived", +}) +_ALLOWED_STATES = frozenset({ + "created", + "return_claim_window_open", + "return_requested", + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", + "talent_pool_retained", + "confirmed_hire_transitioned", + "statutory_retained", + "statutory_retention_expired_destroyed", + "legal_hold_suspended", + "destroyed", +}) +_ALLOWED_RETENTION_ANCHOR_EVENTS = frozenset({ + "hiring_decision_finalized", + "confirmed_hire", + "separation", + "consent_revoked", + "talent_pool_expired", + "statutory_period_start", +}) +_DESTRUCTION_STATES = frozenset({ + "return_destroyed", + "statutory_retention_expired_destroyed", + "destroyed", +}) +_STATUTORY_RETENTION_STATES = frozenset({ + "statutory_retained", + "statutory_retention_expired_destroyed", +}) +_RETURN_REQUEST_EVIDENCE_STATES = frozenset({ + "return_requested", + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", +}) +_RETURN_VERIFIED_EVIDENCE_STATES = frozenset({ + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", +}) +_RETURN_DISPATCH_EVIDENCE_STATES = frozenset({ + "return_dispatched", + "return_delivered", + "return_destroyed", +}) +_RETURN_DELIVERY_EVIDENCE_STATES = frozenset({ + "return_delivered", + "return_destroyed", +}) +_RETURN_PRE_REQUEST_STATES = frozenset({"created", "return_claim_window_open"}) +_REVIEW_STATE = "requires_human_disposition_review" +_NEXT_ACTION = ( + "Within tenant_record_id, verify the disposition event against the authoritative " + "talent_acquisition or people_core record; confirm no conflicting legal hold, " + "then request artifact lifecycle disposition from document_records." +) +_PACKET_FIELDS = ( + "tenant_record_id", + "disposition_reference", + "document_reference", + "candidate_reference", + "hiring_decision_finalized_at", + "return_eligibility", + "state", + "retention_policy_version", + "retention_policy_digest", + "retention_anchor_event", + "retain_until", + "legal_hold", + "purpose_code", + "reason_code", + "evidence_version", + "actor_reference", + "previous_disposition_reference", + "claim_window_end", + "return_request_reference", + "return_requested_at", + "return_request_verified_at", + "return_due_at", + "return_dispatched_at", + "return_delivered_at", + "statutory_retain_until", + "human_confirmation_required", + "review_state", + "next_action", +) +_CandidateDocumentDispositionTuple = namedtuple( + "_CandidateDocumentDispositionTuple", + _PACKET_FIELDS, + module=__name__, +) + + +def _validate_operational_uuid(value: str, field_name: str) -> None: + if type(value) is not str: + raise ValueError(f"{field_name} must be canonical UUID text") + try: + parsed = UUID(value) + except (ValueError, AttributeError, TypeError) as exc: + raise ValueError(f"{field_name} must be canonical UUID text") from exc + if str(parsed) != value or parsed.int in (0, (1 << 128) - 1): + raise ValueError(f"{field_name} must be a canonical operational UUID") + + +def _validate_code(value: str, field_name: str) -> None: + if type(value) is not str or len(value) > 64 or not _CODE_PATTERN.fullmatch(value): + raise ValueError(f"{field_name} must be bounded two-or-more-word lower snake_case") + + +def _validate_digest(value: str, field_name: str) -> None: + if type(value) is not str or not _DIGEST_PATTERN.fullmatch(value): + raise ValueError(f"{field_name} must be lowercase SHA-256 hex") + + +def _validate_reference(value: str, prefix: str, field_name: str) -> None: + message = f"{field_name} must be an opaque {prefix}: reference" + if ( + type(value) is not str + or len(value) > 160 + or not _REFERENCE_PATTERN.fullmatch(value) + or not value.startswith(f"{prefix}:") + ): + raise ValueError(message) + suffix = value.split(":", 1)[1] + try: + parsed = UUID(suffix) + except (ValueError, AttributeError, TypeError) as exc: + raise ValueError(message) from exc + if str(parsed) != suffix or parsed.version != 4 or parsed.int in (0, (1 << 128) - 1): + raise ValueError(message) + + +def _normalize_timestamp(value: datetime) -> datetime: + if type(value) is not datetime or type(value.tzinfo) is not timezone: + raise ValueError("timestamp must use built-in datetime.timezone") + return value.astimezone(timezone.utc) + + +def _canonical_timestamp(value: datetime) -> str: + return _normalize_timestamp(value).isoformat().replace("+00:00", "Z") + + +class CandidateDocumentDisposition(_CandidateDocumentDispositionTuple): + """Structurally immutable, PII-minimized candidate-document disposition packet. + + Construction validates bounded lifecycle evidence, detaches timestamp inputs + into built-in UTC datetimes, and stores the result in tuple-backed immutable + slots. The packet carries disposition intent across talent_acquisition, + people_core, and document_records without granting artifact-lifecycle + authority by itself. + """ + + __slots__ = () + + def __new__( + cls, + tenant_record_id: str, + disposition_reference: str, + document_reference: str, + candidate_reference: str, + hiring_decision_finalized_at: datetime, + return_eligibility: str, + state: str, + retention_policy_version: str, + retention_policy_digest: str, + retention_anchor_event: str, + retain_until: datetime | None, + legal_hold: bool, + purpose_code: str, + reason_code: str, + evidence_version: int, + actor_reference: str, + previous_disposition_reference: str | None = None, + claim_window_end: datetime | None = None, + return_request_reference: str | None = None, + return_requested_at: datetime | None = None, + return_request_verified_at: datetime | None = None, + return_due_at: datetime | None = None, + return_dispatched_at: datetime | None = None, + return_delivered_at: datetime | None = None, + statutory_retain_until: datetime | None = None, + human_confirmation_required: bool = True, + review_state: str = _REVIEW_STATE, + next_action: str = _NEXT_ACTION, + ): + _validate_operational_uuid(tenant_record_id, "tenant_record_id") + _validate_reference( + disposition_reference, + "candidate_document_disposition", + "disposition_reference", + ) + _validate_reference(document_reference, "document", "document_reference") + _validate_reference(candidate_reference, "candidate_profile", "candidate_reference") + hiring_decision_finalized_at = _normalize_timestamp(hiring_decision_finalized_at) + _validate_code(return_eligibility, "return_eligibility") + if return_eligibility not in _ALLOWED_RETURN_ELIGIBILITY_CODES: + raise ValueError("return_eligibility must be an authorized disposition return code") + _validate_code(state, "state") + if state not in _ALLOWED_STATES: + raise ValueError("state must be an authorized disposition state") + _validate_code(retention_policy_version, "retention_policy_version") + _validate_digest(retention_policy_digest, "retention_policy_digest") + _validate_code(retention_anchor_event, "retention_anchor_event") + if retention_anchor_event not in _ALLOWED_RETENTION_ANCHOR_EVENTS: + raise ValueError("retention_anchor_event must be an authorized anchor event") + if retain_until is not None: + retain_until = _normalize_timestamp(retain_until) + if claim_window_end is not None: + claim_window_end = _normalize_timestamp(claim_window_end) + if claim_window_end <= hiring_decision_finalized_at: + raise ValueError("claim_window_end must be after hiring_decision_finalized_at") + if state == "return_claim_window_open" and claim_window_end is None: + raise ValueError("claim_window_end is required while the return claim window is open") + if return_request_reference is not None: + _validate_reference( + return_request_reference, + "candidate_return_request", + "return_request_reference", + ) + if return_requested_at is not None: + return_requested_at = _normalize_timestamp(return_requested_at) + if return_requested_at < hiring_decision_finalized_at: + raise ValueError("return_requested_at cannot precede hiring_decision_finalized_at") + if claim_window_end is not None and return_requested_at > claim_window_end: + raise ValueError("return_requested_at cannot exceed claim_window_end") + if state in _RETURN_REQUEST_EVIDENCE_STATES: + if return_request_reference is None: + raise ValueError("return_request_reference is required once a return request exists") + if return_requested_at is None: + raise ValueError("return_requested_at is required once a return request exists") + if return_request_verified_at is not None: + return_request_verified_at = _normalize_timestamp(return_request_verified_at) + if return_requested_at is None: + raise ValueError("return_request_verified_at requires return_requested_at") + if return_request_verified_at < return_requested_at: + raise ValueError("return_request_verified_at cannot precede return_requested_at") + if state in _RETURN_VERIFIED_EVIDENCE_STATES and return_request_verified_at is None: + raise ValueError( + "return_request_verified_at is required once return-request verification has completed" + ) + if return_due_at is not None: + return_due_at = _normalize_timestamp(return_due_at) + if return_request_verified_at is None: + raise ValueError("return_due_at requires return_request_verified_at") + if return_due_at < return_request_verified_at: + raise ValueError("return_due_at cannot precede return_request_verified_at") + if state in _RETURN_VERIFIED_EVIDENCE_STATES and return_due_at is None: + raise ValueError("return_due_at is required once a return request is verified") + if return_dispatched_at is not None: + return_dispatched_at = _normalize_timestamp(return_dispatched_at) + if return_dispatched_at <= hiring_decision_finalized_at: + raise ValueError("return_dispatched_at must be after hiring_decision_finalized_at") + if return_request_verified_at is not None and return_dispatched_at < return_request_verified_at: + raise ValueError("return_dispatched_at cannot precede return_request_verified_at") + if state in _RETURN_DISPATCH_EVIDENCE_STATES and return_dispatched_at is None: + raise ValueError("return_dispatched_at is required once return dispatch has completed") + if return_delivered_at is not None: + return_delivered_at = _normalize_timestamp(return_delivered_at) + if return_dispatched_at is None: + raise ValueError("return_delivered_at requires return_dispatched_at") + if return_delivered_at < return_dispatched_at: + raise ValueError("return_delivered_at cannot precede return_dispatched_at") + if state in _RETURN_DELIVERY_EVIDENCE_STATES and return_delivered_at is None: + raise ValueError("return_delivered_at is required once return delivery has completed") + if state in _RETURN_PRE_REQUEST_STATES and ( + return_request_reference is not None + or return_requested_at is not None + or return_request_verified_at is not None + or return_due_at is not None + or return_dispatched_at is not None + or return_delivered_at is not None + ): + raise ValueError("return request evidence cannot precede return_requested state") + if state == "return_requested" and ( + return_request_verified_at is not None + or return_due_at is not None + or return_dispatched_at is not None + or return_delivered_at is not None + ): + raise ValueError("verification evidence cannot precede return_request_verified state") + if state == "return_request_verified" and ( + return_dispatched_at is not None or return_delivered_at is not None + ): + raise ValueError("dispatch evidence cannot precede return_dispatched state") + if state == "return_dispatched" and return_delivered_at is not None: + raise ValueError("delivery evidence cannot precede return_delivered state") + if statutory_retain_until is not None: + statutory_retain_until = _normalize_timestamp(statutory_retain_until) + if state in _STATUTORY_RETENTION_STATES and statutory_retain_until is None: + raise ValueError( + "statutory_retain_until is required for statutory-retention states" + ) + if type(legal_hold) is not bool: + raise ValueError("legal_hold must be an exact boolean") + if state == "legal_hold_suspended" and not legal_hold: + raise ValueError("legal_hold_suspended requires an active legal_hold") + _validate_code(purpose_code, "purpose_code") + if purpose_code != _DISPOSITION_PURPOSE: + raise ValueError("purpose_code must remain candidate_document_disposition") + _validate_code(reason_code, "reason_code") + if reason_code not in _ALLOWED_REASON_CODES: + raise ValueError("reason_code must be an authorized disposition reason") + if type(evidence_version) is not int or not 1 <= evidence_version <= 2_147_483_647: + raise ValueError("evidence_version must be an integer from 1 through 2147483647") + _validate_reference(actor_reference, "actor", "actor_reference") + if previous_disposition_reference is not None: + _validate_reference( + previous_disposition_reference, + "candidate_document_disposition", + "previous_disposition_reference", + ) + if human_confirmation_required is not True: + raise ValueError("human confirmation is mandatory for document disposition") + if type(review_state) is not str: + raise ValueError("review_state must be an exact string") + if review_state != _REVIEW_STATE: + raise ValueError("review_state must remain requires_human_disposition_review") + if type(next_action) is not str: + raise ValueError("next_action must be an exact string") + if next_action != _NEXT_ACTION: + raise ValueError("next_action must remain the governed disposition instruction") + if legal_hold and state in _DESTRUCTION_STATES: + raise ValueError("a document under legal hold cannot be destroyed") + + return super().__new__( + cls, + tenant_record_id, + disposition_reference, + document_reference, + candidate_reference, + hiring_decision_finalized_at, + return_eligibility, + state, + retention_policy_version, + retention_policy_digest, + retention_anchor_event, + retain_until, + legal_hold, + purpose_code, + reason_code, + evidence_version, + actor_reference, + previous_disposition_reference, + claim_window_end, + return_request_reference, + return_requested_at, + return_request_verified_at, + return_due_at, + return_dispatched_at, + return_delivered_at, + statutory_retain_until, + human_confirmation_required, + review_state, + next_action, + ) + + def __repr__(self) -> str: + return "CandidateDocumentDisposition()" + + def canonical_json(self) -> str: + """Return deterministic canonical JSON for the already validated packet. + + Construction performs all input validation and raises ``ValueError`` for + invalid lifecycle evidence. This method serializes the detached immutable + state with stable key ordering and UTC timestamps; it does not re-authorize + a disposition or execute a document lifecycle operation. + """ + payload = { + "actor_reference": self.actor_reference, + "candidate_reference": self.candidate_reference, + "claim_window_end": _canonical_timestamp(self.claim_window_end) if self.claim_window_end is not None else None, + "disposition_reference": self.disposition_reference, + "document_reference": self.document_reference, + "evidence_version": self.evidence_version, + "hiring_decision_finalized_at": _canonical_timestamp(self.hiring_decision_finalized_at), + "human_confirmation_required": self.human_confirmation_required, + "legal_hold": self.legal_hold, + "next_action": self.next_action, + "previous_disposition_reference": self.previous_disposition_reference, + "purpose_code": self.purpose_code, + "reason_code": self.reason_code, + "retain_until": _canonical_timestamp(self.retain_until) if self.retain_until is not None else None, + "retention_anchor_event": self.retention_anchor_event, + "retention_policy_digest": self.retention_policy_digest, + "retention_policy_version": self.retention_policy_version, + "return_delivered_at": _canonical_timestamp(self.return_delivered_at) if self.return_delivered_at is not None else None, + "return_dispatched_at": _canonical_timestamp(self.return_dispatched_at) if self.return_dispatched_at is not None else None, + "return_due_at": _canonical_timestamp(self.return_due_at) if self.return_due_at is not None else None, + "return_eligibility": self.return_eligibility, + "return_request_reference": self.return_request_reference, + "return_request_verified_at": _canonical_timestamp(self.return_request_verified_at) if self.return_request_verified_at is not None else None, + "return_requested_at": _canonical_timestamp(self.return_requested_at) if self.return_requested_at is not None else None, + "review_state": self.review_state, + "state": self.state, + "statutory_retain_until": _canonical_timestamp(self.statutory_retain_until) if self.statutory_retain_until is not None else None, + "tenant_record_id": self.tenant_record_id, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + def sha256_digest(self) -> str: + """Return the lowercase SHA-256 digest of ``canonical_json()``. + + The digest is content-addressing evidence only. Invalid construction + inputs have already raised ``ValueError``; this method does not turn the + packet into authentication or authorization authority. + """ + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +def build_candidate_document_disposition( + *, + tenant_record_id: str, + disposition_reference: str, + document_reference: str, + candidate_reference: str, + hiring_decision_finalized_at: datetime, + return_eligibility: str, + state: str, + retention_policy_version: str, + retention_policy_digest: str, + retention_anchor_event: str, + retain_until: datetime | None = None, + legal_hold: bool = False, + purpose_code: str, + reason_code: str, + evidence_version: int = 1, + actor_reference: str, + previous_disposition_reference: str | None = None, + claim_window_end: datetime | None = None, + return_request_reference: str | None = None, + return_requested_at: datetime | None = None, + return_request_verified_at: datetime | None = None, + return_due_at: datetime | None = None, + return_dispatched_at: datetime | None = None, + return_delivered_at: datetime | None = None, + statutory_retain_until: datetime | None = None, +) -> CandidateDocumentDisposition: + """Validate lifecycle evidence and return an immutable disposition packet. + + The same constructor validation used by ``CandidateDocumentDisposition`` is + applied to every argument. ``ValueError`` is raised when an identifier, + controlled code, timestamp, lifecycle ordering rule, legal-hold rule, or + governed constant is invalid. Valid timestamps are detached into built-in + UTC datetimes before retention. + """ + return CandidateDocumentDisposition( + tenant_record_id=tenant_record_id, + disposition_reference=disposition_reference, + document_reference=document_reference, + candidate_reference=candidate_reference, + hiring_decision_finalized_at=hiring_decision_finalized_at, + return_eligibility=return_eligibility, + state=state, + retention_policy_version=retention_policy_version, + retention_policy_digest=retention_policy_digest, + retention_anchor_event=retention_anchor_event, + retain_until=retain_until, + legal_hold=legal_hold, + purpose_code=purpose_code, + reason_code=reason_code, + evidence_version=evidence_version, + actor_reference=actor_reference, + previous_disposition_reference=previous_disposition_reference, + claim_window_end=claim_window_end, + return_request_reference=return_request_reference, + return_requested_at=return_requested_at, + return_request_verified_at=return_request_verified_at, + return_due_at=return_due_at, + return_dispatched_at=return_dispatched_at, + return_delivered_at=return_delivered_at, + statutory_retain_until=statutory_retain_until, + ) diff --git a/packages/candidate-document-disposition/tests/test_legal_hold_state.py b/packages/candidate-document-disposition/tests/test_legal_hold_state.py new file mode 100644 index 000000000..31f2c8d22 --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_legal_hold_state.py @@ -0,0 +1,37 @@ +from datetime import datetime, timezone + +import pytest + +from orgmetra_candidate_document_disposition import build_candidate_document_disposition + + +def _build(*, legal_hold: bool): + return build_candidate_document_disposition( + tenant_record_id="00000000-0000-4000-a000-000000000001", + disposition_reference="candidate_document_disposition:00000000-0000-4000-a000-000000000010", + document_reference="document:00000000-0000-4000-a000-000000000020", + candidate_reference="candidate_profile:00000000-0000-4000-a000-000000000030", + hiring_decision_finalized_at=datetime(2026, 9, 11, 12, 0, 0, tzinfo=timezone.utc), + return_eligibility="eligible", + state="legal_hold_suspended", + retention_policy_version="v2026_09", + retention_policy_digest="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + retention_anchor_event="hiring_decision_finalized", + retain_until=datetime(2027, 9, 11, 12, 0, 0, tzinfo=timezone.utc), + legal_hold=legal_hold, + purpose_code="candidate_document_disposition", + reason_code="legal_hold_release", + evidence_version=1, + actor_reference="actor:00000000-0000-4000-a000-000000000040", + ) + + +def test_legal_hold_suspended_rejects_inactive_hold(): + with pytest.raises(ValueError, match="legal_hold_suspended"): + _build(legal_hold=False) + + +def test_legal_hold_suspended_accepts_active_hold(): + packet = _build(legal_hold=True) + assert packet.state == "legal_hold_suspended" + assert packet.legal_hold is True diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py new file mode 100644 index 000000000..4c847cac9 --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -0,0 +1,434 @@ +import json +from datetime import datetime, timedelta, timezone +from hashlib import sha256 + +import pytest + +from orgmetra_candidate_document_disposition import ( + CandidateDocumentDisposition, + build_candidate_document_disposition, +) + + +def _default_kwargs() -> dict: + return dict( + tenant_record_id="00000000-0000-4000-a000-000000000001", + disposition_reference="candidate_document_disposition:00000000-0000-4000-a000-000000000010", + document_reference="document:00000000-0000-4000-a000-000000000020", + candidate_reference="candidate_profile:00000000-0000-4000-a000-000000000030", + hiring_decision_finalized_at=datetime(2026, 9, 11, 12, 0, 0, tzinfo=timezone.utc), + return_eligibility="eligible", + state="created", + retention_policy_version="v2026_09", + retention_policy_digest="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + retention_anchor_event="hiring_decision_finalized", + retain_until=datetime(2027, 9, 11, 12, 0, 0, tzinfo=timezone.utc), + legal_hold=False, + purpose_code="candidate_document_disposition", + reason_code="non_selected_applicant_return", + evidence_version=1, + actor_reference="actor:00000000-0000-4000-a000-000000000040", + ) + + +def _build(**overrides) -> CandidateDocumentDisposition: + kwargs = _default_kwargs() + kwargs.update(overrides) + return build_candidate_document_disposition(**kwargs) + + +def _return_request_evidence() -> dict: + verified_at = datetime(2026, 9, 11, 14, 0, 0, tzinfo=timezone.utc) + return { + "return_request_reference": "candidate_return_request:00000000-0000-4000-a000-000000000050", + "return_requested_at": datetime(2026, 9, 11, 13, 0, 0, tzinfo=timezone.utc), + "return_request_verified_at": verified_at, + "return_due_at": verified_at + timedelta(days=14), + } + + +class TestConstruction: + def test_default_packet(self): + p = _build() + assert isinstance(p, CandidateDocumentDisposition) + assert p.legal_hold is False + assert p.state == "created" + + def test_optional_previous_disposition(self): + p = _build( + previous_disposition_reference="candidate_document_disposition:00000000-0000-4000-a000-000000000099" + ) + assert p.previous_disposition_reference is not None + assert "000000000099" in p.previous_disposition_reference + + def test_optional_claim_window_end(self): + ts = datetime(2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc) + p = _build(claim_window_end=ts) + assert p.claim_window_end == ts + + def test_optional_return_dispatched(self): + ts = datetime(2026, 9, 20, 12, 0, 0, tzinfo=timezone.utc) + p = _build( + state="return_dispatched", + return_dispatched_at=ts, + **_return_request_evidence(), + ) + assert p.return_dispatched_at == ts + + def test_optional_statutory_retain_until(self): + ts = datetime(2031, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + p = _build(statutory_retain_until=ts) + assert p.statutory_retain_until == ts + + def test_optional_retain_until_none(self): + p = _build(retain_until=None) + assert p.retain_until is None + + def test_legal_hold_true(self): + p = _build(legal_hold=True) + assert p.legal_hold is True + + def test_legal_hold_and_destroyed_raises(self): + with pytest.raises(ValueError, match="legal hold"): + _build(legal_hold=True, state="destroyed") + + @pytest.mark.parametrize("reason_code", [ + "talent_pool_consent_withdrawal", + "confirmed_hire_materialization", + "statutory_retention_expiry", + "legal_hold_release", + "talent_pool_expiry", + ]) + def test_various_reason_codes(self, reason_code): + p = _build(reason_code=reason_code) + assert p.reason_code == reason_code + + def test_talent_pool_consent_withdrawal_reason(self): + p = _build(reason_code="talent_pool_consent_withdrawal") + assert p.reason_code == "talent_pool_consent_withdrawal" + + def test_confirmed_hire_materialization_reason(self): + p = _build(reason_code="confirmed_hire_materialization") + assert p.reason_code == "confirmed_hire_materialization" + + def test_statutory_retention_expiry_reason(self): + p = _build(reason_code="statutory_retention_expiry") + assert p.reason_code == "statutory_retention_expiry" + + def test_legal_hold_release_reason(self): + p = _build(reason_code="legal_hold_release") + assert p.reason_code == "legal_hold_release" + + def test_talent_pool_expiry_reason(self): + p = _build(reason_code="talent_pool_expiry") + assert p.reason_code == "talent_pool_expiry" + + @pytest.mark.parametrize("code", [ + "eligible", + "return_exception_voluntary_submission", + "return_exception_not_requested_by_employer", + "waived", + ]) + def test_various_return_eligibility_codes(self, code): + p = _build(return_eligibility=code) + assert p.return_eligibility == code + + @pytest.mark.parametrize("state", [ + "created", + "return_claim_window_open", + "return_requested", + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", + "talent_pool_retained", + "confirmed_hire_transitioned", + "statutory_retained", + "statutory_retention_expired_destroyed", + "legal_hold_suspended", + "destroyed", + ]) + def test_various_states(self, state): + dispatch_at = datetime(2026, 9, 20, 12, 0, 0, tzinfo=timezone.utc) + delivery_at = datetime(2026, 9, 21, 12, 0, 0, tzinfo=timezone.utc) + evidence = {} + if state == "return_claim_window_open": + evidence["claim_window_end"] = datetime(2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc) + if state in {"statutory_retained", "statutory_retention_expired_destroyed"}: + evidence["statutory_retain_until"] = datetime( + 2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc + ) + if state == "legal_hold_suspended": + evidence["legal_hold"] = True + if state in { + "return_requested", + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", + }: + request_evidence = _return_request_evidence() + evidence["return_request_reference"] = request_evidence["return_request_reference"] + evidence["return_requested_at"] = request_evidence["return_requested_at"] + if state in { + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", + }: + request_evidence = _return_request_evidence() + evidence["return_request_verified_at"] = request_evidence["return_request_verified_at"] + evidence["return_due_at"] = request_evidence["return_due_at"] + if state in {"return_dispatched", "return_delivered", "return_destroyed"}: + evidence["return_dispatched_at"] = dispatch_at + if state in {"return_delivered", "return_destroyed"}: + evidence["return_delivered_at"] = delivery_at + p = _build(state=state, **evidence) + assert p.state == state + + @pytest.mark.parametrize("event", [ + "hiring_decision_finalized", + "confirmed_hire", + "separation", + "consent_revoked", + "talent_pool_expired", + "statutory_period_start", + ]) + def test_various_anchor_events(self, event): + p = _build(retention_anchor_event=event) + assert p.retention_anchor_event == event + + def test_repr_redacted(self): + p = _build() + assert "" in repr(p) + + @pytest.mark.parametrize("version", [1, 2_147_483_647]) + def test_evidence_version_boundary(self, version): + p = _build(evidence_version=version) + assert p.evidence_version == version + + def test_evidence_version_boundary_low(self): + p = _build(evidence_version=1) + assert p.evidence_version == 1 + + def test_evidence_version_boundary_high(self): + p = _build(evidence_version=2_147_483_647) + assert p.evidence_version == 2_147_483_647 + + def test_evidence_version_minimum(self): + with pytest.raises(ValueError, match="evidence_version"): + _build(evidence_version=0) + + def test_evidence_version_maximum(self): + with pytest.raises(ValueError, match="evidence_version"): + _build(evidence_version=2_147_483_648) + + def test_invalid_tenant_uuid(self): + with pytest.raises(ValueError, match="tenant_record_id"): + _build(tenant_record_id="not-a-uuid") + + def test_sentinel_tenant_uuid(self): + with pytest.raises(ValueError, match="tenant_record_id"): + _build(tenant_record_id="00000000-0000-0000-0000-000000000000") + + def test_invalid_disposition_reference(self): + with pytest.raises(ValueError, match="disposition_reference"): + _build(disposition_reference="bad-ref") + + def test_invalid_document_reference(self): + with pytest.raises(ValueError, match="document_reference"): + _build(document_reference="bad-ref") + + def test_invalid_candidate_reference(self): + with pytest.raises(ValueError, match="candidate_reference"): + _build(candidate_reference="bad-ref") + + def test_non_uuid4_disposition_reference(self): + with pytest.raises(ValueError, match="disposition_reference"): + _build(disposition_reference="candidate_document_disposition:00000000-0000-3000-a000-000000000000") + + def test_unrecognized_retention_anchor_event(self): + with pytest.raises(ValueError, match="authorized anchor event"): + _build(retention_anchor_event="unknown_event") + + def test_unrecognized_reason_code(self): + with pytest.raises(ValueError, match="authorized disposition reason"): + _build(reason_code="unknown_reason_code") + + def test_invalid_return_eligibility(self): + with pytest.raises(ValueError, match="return_eligibility"): + _build(return_eligibility="not-a-valid-code") + + def test_unrecognized_return_eligibility(self): + with pytest.raises(ValueError, match="authorized disposition return code"): + _build(return_eligibility="unknown_return_code") + + def test_invalid_state(self): + with pytest.raises(ValueError, match="state"): + _build(state="not-a-valid-state") + + def test_unrecognized_state(self): + with pytest.raises(ValueError, match="authorized disposition state"): + _build(state="unknown_state") + + def test_invalid_retention_policy_version_code(self): + with pytest.raises(ValueError, match="retention_policy_version"): + _build(retention_policy_version="BAD") + + def test_invalid_retention_policy_digest(self): + with pytest.raises(ValueError, match="retention_policy_digest"): + _build(retention_policy_digest="xyz") + + def test_invalid_retention_anchor_event(self): + with pytest.raises(ValueError, match="retention_anchor_event"): + _build(retention_anchor_event="BAD") + + def test_invalid_purpose_code(self): + with pytest.raises(ValueError, match="purpose_code"): + _build(purpose_code="something_else") + + def test_invalid_reason_code(self): + with pytest.raises(ValueError, match="reason_code"): + _build(reason_code="BAD") + + def test_invalid_actor_reference(self): + with pytest.raises(ValueError, match="actor_reference"): + _build(actor_reference="actor:bad-uuid") + + def test_human_confirmation_false(self): + kwargs = _default_kwargs() + with pytest.raises(ValueError, match="human confirmation"): + CandidateDocumentDisposition(**kwargs, human_confirmation_required=False) + + def test_wrong_review_state(self): + kwargs = _default_kwargs() + with pytest.raises(ValueError, match="review_state"): + CandidateDocumentDisposition(**kwargs, review_state="approved") + + def test_wrong_next_action(self): + kwargs = _default_kwargs() + with pytest.raises(ValueError, match="next_action"): + CandidateDocumentDisposition(**kwargs, next_action="do something else") + + def test_naive_datetime_raises(self): + with pytest.raises(ValueError, match="timestamp"): + _build(hiring_decision_finalized_at=datetime(2026, 9, 11, 12, 0, 0)) + + def test_naive_retain_until_raises(self): + with pytest.raises(ValueError, match="timestamp"): + _build(retain_until=datetime(2026, 9, 11, 12, 0, 0)) + + def test_invalid_previous_disposition_reference(self): + with pytest.raises(ValueError, match="previous_disposition_reference"): + _build(previous_disposition_reference="bad:ref") + + +class TestCanonicalJson: + def test_deterministic(self): + p1 = _build() + p2 = _build() + assert p1.canonical_json() == p2.canonical_json() + + def test_contains_required_fields(self): + p = _build() + doc = json.loads(p.canonical_json()) + assert doc["purpose_code"] == "candidate_document_disposition" + assert doc["state"] == "created" + assert doc["legal_hold"] is False + + def test_omits_none_optional_fields(self): + p = _build( + previous_disposition_reference=None, + claim_window_end=None, + return_request_reference=None, + return_requested_at=None, + return_request_verified_at=None, + return_due_at=None, + return_dispatched_at=None, + return_delivered_at=None, + statutory_retain_until=None, + ) + doc = json.loads(p.canonical_json()) + assert doc["previous_disposition_reference"] is None + assert doc["claim_window_end"] is None + assert doc["return_request_reference"] is None + assert doc["return_requested_at"] is None + assert doc["return_request_verified_at"] is None + assert doc["return_due_at"] is None + assert doc["return_dispatched_at"] is None + assert doc["return_delivered_at"] is None + assert doc["statutory_retain_until"] is None + + def test_includes_set_optional_fields(self): + request_evidence = _return_request_evidence() + dispatch_at = datetime(2026, 9, 20, 12, 0, 0, tzinfo=timezone.utc) + delivery_at = datetime(2026, 9, 21, 12, 0, 0, tzinfo=timezone.utc) + p = _build( + state="return_delivered", + previous_disposition_reference="candidate_document_disposition:00000000-0000-4000-a000-000000000099", + claim_window_end=datetime(2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc), + return_dispatched_at=dispatch_at, + return_delivered_at=delivery_at, + statutory_retain_until=datetime(2031, 1, 1, 0, 0, 0, tzinfo=timezone.utc), + **request_evidence, + ) + doc = json.loads(p.canonical_json()) + assert doc["previous_disposition_reference"] is not None + assert doc["claim_window_end"] is not None + assert doc["return_request_reference"] is not None + assert doc["return_requested_at"] is not None + assert doc["return_request_verified_at"] is not None + assert doc["return_due_at"] is not None + assert doc["return_dispatched_at"] is not None + assert doc["return_delivered_at"] is not None + assert doc["statutory_retain_until"] is not None + + def test_sort_keys(self): + p = _build() + doc = json.loads(p.canonical_json()) + keys = list(doc.keys()) + assert keys == sorted(keys) + + +class TestSha256Digest: + def test_deterministic(self): + p = _build() + assert p.sha256_digest() == p.sha256_digest() + + def test_matches_canonical_json(self): + p = _build() + expected = sha256(p.canonical_json().encode("utf-8")).hexdigest() + assert p.sha256_digest() == expected + + def test_changes_when_field_changes(self): + p1 = _build(state="created") + p2 = _build(state="destroyed") + assert p1.sha256_digest() != p2.sha256_digest() + + +class TestBuildFunction: + def test_returns_packet(self): + p = build_candidate_document_disposition(**_default_kwargs()) + assert isinstance(p, CandidateDocumentDisposition) + + def test_passes_all_fields(self): + kwargs = _default_kwargs() + p = build_candidate_document_disposition(**kwargs) + for k, v in kwargs.items(): + assert getattr(p, k) == v + + +class TestRegressionInvariants: + def test_return_claim_window_anchored_to_hiring_decision(self): + p = _build() + assert p.hiring_decision_finalized_at is not None + assert p.claim_window_end is None or p.claim_window_end > p.hiring_decision_finalized_at + + def test_legal_hold_overrides_ordinary_retention(self): + p = _build(legal_hold=True) + assert p.legal_hold is True + assert p.retain_until is not None + + def test_retention_policy_version_is_code(self): + p = _build(retention_policy_version="v2026_09") + assert p.retention_policy_version == "v2026_09" diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py new file mode 100644 index 000000000..4917bf44d --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -0,0 +1,350 @@ +from datetime import datetime, timedelta, timezone, tzinfo + +import pytest + +from orgmetra_candidate_document_disposition import ( + CandidateDocumentDisposition, + build_candidate_document_disposition, +) + + +def _kwargs() -> dict: + return { + "tenant_record_id": "00000000-0000-4000-a000-000000000001", + "disposition_reference": "candidate_document_disposition:00000000-0000-4000-a000-000000000010", + "document_reference": "document:00000000-0000-4000-a000-000000000020", + "candidate_reference": "candidate_profile:00000000-0000-4000-a000-000000000030", + "hiring_decision_finalized_at": datetime(2026, 9, 11, 12, 0, 0, tzinfo=timezone.utc), + "return_eligibility": "eligible", + "state": "created", + "retention_policy_version": "v2026_09", + "retention_policy_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "retention_anchor_event": "hiring_decision_finalized", + "retain_until": datetime(2027, 9, 11, 12, 0, 0, tzinfo=timezone.utc), + "legal_hold": False, + "purpose_code": "candidate_document_disposition", + "reason_code": "non_selected_applicant_return", + "evidence_version": 1, + "actor_reference": "actor:00000000-0000-4000-a000-000000000040", + } + + +def _build(**overrides): + kwargs = _kwargs() + kwargs.update(overrides) + return build_candidate_document_disposition(**kwargs) + + +def _verified_return_evidence() -> dict: + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + verified_at = hiring_decision + timedelta(hours=2) + return { + "return_request_reference": "candidate_return_request:00000000-0000-4000-a000-000000000050", + "return_requested_at": hiring_decision + timedelta(hours=1), + "return_request_verified_at": verified_at, + "return_due_at": verified_at + timedelta(days=14), + } + + +class _ExecutableTimezone(tzinfo): + def __init__(self) -> None: + self.utcoffset_calls = 0 + + def utcoffset(self, dt): + self.utcoffset_calls += 1 + return timedelta(0) + + def dst(self, dt): + return timedelta(0) + + +class _TextSubtype(str): + pass + + +def test_custom_timezone_is_rejected_before_callback_execution(): + caller_timezone = _ExecutableTimezone() + + with pytest.raises(ValueError, match="timestamp"): + _build( + hiring_decision_finalized_at=datetime( + 2026, + 9, + 11, + 12, + 0, + 0, + tzinfo=caller_timezone, + ) + ) + + assert caller_timezone.utcoffset_calls == 0 + + +def test_builtin_fixed_offset_timezone_is_detached_to_utc(): + fixed_offset = timezone(timedelta(hours=9)) + packet = _build( + hiring_decision_finalized_at=datetime( + 2026, + 9, + 11, + 21, + 0, + 0, + tzinfo=fixed_offset, + ) + ) + + assert packet.hiring_decision_finalized_at == datetime( + 2026, + 9, + 11, + 12, + 0, + 0, + tzinfo=timezone.utc, + ) + assert packet.hiring_decision_finalized_at.tzinfo is timezone.utc + + +def test_legal_hold_must_be_exact_boolean(): + with pytest.raises(ValueError, match="legal_hold"): + _build(legal_hold=1) + + +@pytest.mark.parametrize("offset", [timedelta(0), -timedelta(seconds=1)]) +def test_claim_window_must_follow_hiring_decision(offset): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + with pytest.raises(ValueError, match="claim_window_end"): + _build(claim_window_end=hiring_decision + offset) + + +def test_claim_window_open_state_requires_window_end(): + with pytest.raises(ValueError, match="claim_window_end"): + _build(state="return_claim_window_open", claim_window_end=None) + + +def test_return_request_cannot_exceed_claim_window(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + with pytest.raises(ValueError, match="return_requested_at"): + _build( + state="return_requested", + claim_window_end=hiring_decision + timedelta(minutes=30), + **evidence, + ) + + +@pytest.mark.parametrize( + "state", + ["return_destroyed", "statutory_retention_expired_destroyed", "destroyed"], +) +def test_legal_hold_rejects_every_destruction_state(state): + evidence = {} + if state == "return_destroyed": + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence.update(_verified_return_evidence()) + evidence["return_dispatched_at"] = hiring_decision + timedelta(days=1) + evidence["return_delivered_at"] = hiring_decision + timedelta(days=2) + if state == "statutory_retention_expired_destroyed": + evidence["statutory_retain_until"] = ( + _kwargs()["hiring_decision_finalized_at"] + timedelta(days=30) + ) + with pytest.raises(ValueError, match="legal hold"): + _build(legal_hold=True, state=state, **evidence) + + +@pytest.mark.parametrize( + "state", + [ + "return_requested", + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", + ], +) +def test_return_request_lifecycle_requires_request_receipt(state): + with pytest.raises(ValueError, match="return_request_reference"): + _build(state=state) + + +@pytest.mark.parametrize( + "state", + [ + "return_requested", + "return_request_verified", + "return_dispatched", + "return_delivered", + "return_destroyed", + ], +) +def test_return_request_lifecycle_requires_request_timestamp(state): + evidence = _verified_return_evidence() + evidence["return_requested_at"] = None + with pytest.raises(ValueError, match="return_requested_at"): + _build(state=state, **evidence) + + +def test_return_request_cannot_precede_hiring_decision(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + evidence["return_requested_at"] = hiring_decision - timedelta(seconds=1) + with pytest.raises(ValueError, match="return_requested_at"): + _build(state="return_requested", **evidence) + + +def test_verified_and_later_return_states_require_verification_timestamp(): + evidence = _verified_return_evidence() + evidence["return_request_verified_at"] = None + with pytest.raises(ValueError, match="return_request_verified_at"): + _build(state="return_request_verified", **evidence) + + +def test_return_verification_cannot_precede_request(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + evidence["return_requested_at"] = hiring_decision + timedelta(hours=2) + evidence["return_request_verified_at"] = hiring_decision + timedelta(hours=1) + with pytest.raises(ValueError, match="return_request_verified_at"): + _build(state="return_request_verified", **evidence) + + +def test_verified_return_requires_policy_computed_due_at(): + evidence = _verified_return_evidence() + evidence["return_due_at"] = None + with pytest.raises(ValueError, match="return_due_at"): + _build(state="return_request_verified", **evidence) + + +def test_return_due_at_cannot_precede_verification(): + evidence = _verified_return_evidence() + evidence["return_due_at"] = evidence["return_request_verified_at"] - timedelta(seconds=1) + with pytest.raises(ValueError, match="return_due_at"): + _build(state="return_request_verified", **evidence) + + +def test_dispatch_cannot_precede_verified_request(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + evidence["return_request_verified_at"] = hiring_decision + timedelta(days=2) + evidence["return_due_at"] = hiring_decision + timedelta(days=16) + with pytest.raises(ValueError, match="return_dispatched_at"): + _build( + state="return_dispatched", + return_dispatched_at=hiring_decision + timedelta(days=1), + **evidence, + ) + + +@pytest.mark.parametrize( + "state", + ["return_dispatched", "return_delivered", "return_destroyed"], +) +def test_return_dispatch_states_require_dispatch_timestamp(state): + evidence = _verified_return_evidence() + with pytest.raises(ValueError, match="return_dispatched_at"): + _build(state=state, return_dispatched_at=None, **evidence) + + +@pytest.mark.parametrize("offset", [timedelta(0), -timedelta(seconds=1)]) +def test_return_dispatch_timestamp_must_follow_hiring_decision(offset): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + with pytest.raises(ValueError, match="return_dispatched_at"): + _build( + state="return_dispatched", + return_dispatched_at=hiring_decision + offset, + **evidence, + ) + + +@pytest.mark.parametrize("state", ["return_delivered", "return_destroyed"]) +def test_return_completion_states_require_delivery_receipt(state): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + with pytest.raises(ValueError, match="return_delivered_at"): + _build( + state=state, + return_dispatched_at=hiring_decision + timedelta(days=1), + return_delivered_at=None, + **evidence, + ) + + +def test_return_delivery_cannot_precede_dispatch(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + dispatched_at = hiring_decision + timedelta(days=2) + with pytest.raises(ValueError, match="return_delivered_at"): + _build( + state="return_delivered", + return_dispatched_at=dispatched_at, + return_delivered_at=dispatched_at - timedelta(seconds=1), + **evidence, + ) + + +def test_return_destroyed_accepts_verified_request_and_delivery_receipt(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + evidence = _verified_return_evidence() + dispatched_at = hiring_decision + timedelta(days=1) + delivered_at = dispatched_at + timedelta(days=1) + packet = _build( + state="return_destroyed", + return_dispatched_at=dispatched_at, + return_delivered_at=delivered_at, + **evidence, + ) + + assert packet.return_request_reference == evidence["return_request_reference"] + assert packet.return_request_verified_at == evidence["return_request_verified_at"] + assert packet.return_due_at == evidence["return_due_at"] + assert packet.return_dispatched_at == dispatched_at + assert packet.return_delivered_at == delivered_at + + +def test_packet_state_cannot_be_rewritten_after_validation(): + packet = _build(legal_hold=True) + + with pytest.raises((AttributeError, TypeError)): + object.__setattr__(packet, "state", "destroyed") + + assert packet.state == "created" + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("tenant_record_id", "00000000-0000-4000-a000-000000000001"), + ("document_reference", "document:00000000-0000-4000-a000-000000000020"), + ("retention_policy_version", "v2026_09"), + ( + "retention_policy_digest", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ), + ], +) +def test_trust_bearing_text_rejects_string_subtypes(field_name, value): + with pytest.raises(ValueError, match=field_name): + _build(**{field_name: _TextSubtype(value)}) + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("review_state", "requires_human_disposition_review"), + ( + "next_action", + "Within tenant_record_id, verify the disposition event against the authoritative " + "talent_acquisition or people_core record; confirm no conflicting legal hold, " + "then request artifact lifecycle disposition from document_records.", + ), + ], +) +def test_governed_constant_text_rejects_string_subtypes(field_name, value): + kwargs = _kwargs() + kwargs[field_name] = _TextSubtype(value) + + with pytest.raises(ValueError, match=field_name): + CandidateDocumentDisposition(**kwargs) diff --git a/packages/candidate-document-disposition/tests/test_return_state_evidence_envelope.py b/packages/candidate-document-disposition/tests/test_return_state_evidence_envelope.py new file mode 100644 index 000000000..cb5afaed6 --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_return_state_evidence_envelope.py @@ -0,0 +1,129 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from orgmetra_candidate_document_disposition import build_candidate_document_disposition + + +_HIRING_DECISION = datetime(2026, 9, 11, 12, 0, 0, tzinfo=timezone.utc) +_REQUESTED_AT = _HIRING_DECISION + timedelta(hours=1) +_VERIFIED_AT = _HIRING_DECISION + timedelta(hours=2) +_DUE_AT = _VERIFIED_AT + timedelta(days=14) +_DISPATCHED_AT = _HIRING_DECISION + timedelta(days=1) +_DELIVERED_AT = _HIRING_DECISION + timedelta(days=2) +_REQUEST_REFERENCE = "candidate_return_request:00000000-0000-4000-a000-000000000050" + + +def _build(state: str, **overrides): + kwargs = { + "tenant_record_id": "00000000-0000-4000-a000-000000000001", + "disposition_reference": "candidate_document_disposition:00000000-0000-4000-a000-000000000010", + "document_reference": "document:00000000-0000-4000-a000-000000000020", + "candidate_reference": "candidate_profile:00000000-0000-4000-a000-000000000030", + "hiring_decision_finalized_at": _HIRING_DECISION, + "return_eligibility": "eligible", + "state": state, + "retention_policy_version": "v2026_09", + "retention_policy_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "retention_anchor_event": "hiring_decision_finalized", + "retain_until": datetime(2027, 9, 11, 12, 0, 0, tzinfo=timezone.utc), + "legal_hold": False, + "purpose_code": "candidate_document_disposition", + "reason_code": "non_selected_applicant_return", + "evidence_version": 1, + "actor_reference": "actor:00000000-0000-4000-a000-000000000040", + } + kwargs.update(overrides) + return build_candidate_document_disposition(**kwargs) + + +@pytest.mark.parametrize("state", ["created", "return_claim_window_open"]) +def test_pre_request_state_rejects_return_request_evidence(state): + extra = {} + if state == "return_claim_window_open": + extra["claim_window_end"] = _HIRING_DECISION + timedelta(days=30) + + with pytest.raises(ValueError, match="return request evidence"): + _build( + state, + return_request_reference=_REQUEST_REFERENCE, + return_requested_at=_REQUESTED_AT, + **extra, + ) + + +@pytest.mark.parametrize("state", ["created", "return_claim_window_open"]) +@pytest.mark.parametrize( + "future_evidence", + [ + {"return_dispatched_at": _DISPATCHED_AT}, + { + "return_dispatched_at": _DISPATCHED_AT, + "return_delivered_at": _DELIVERED_AT, + }, + ], +) +def test_pre_request_state_rejects_dispatch_or_delivery_evidence(state, future_evidence): + extra = dict(future_evidence) + if state == "return_claim_window_open": + extra["claim_window_end"] = _HIRING_DECISION + timedelta(days=30) + + with pytest.raises(ValueError, match="return request evidence"): + _build(state, **extra) + + +def test_return_requested_state_rejects_verification_evidence(): + with pytest.raises(ValueError, match="verification evidence"): + _build( + "return_requested", + return_request_reference=_REQUEST_REFERENCE, + return_requested_at=_REQUESTED_AT, + return_request_verified_at=_VERIFIED_AT, + return_due_at=_DUE_AT, + ) + + +def test_return_requested_state_rejects_dispatch_evidence(): + with pytest.raises(ValueError, match="verification evidence"): + _build( + "return_requested", + return_request_reference=_REQUEST_REFERENCE, + return_requested_at=_REQUESTED_AT, + return_dispatched_at=_DISPATCHED_AT, + ) + + +def test_return_requested_state_rejects_delivery_evidence(): + with pytest.raises(ValueError, match="verification evidence"): + _build( + "return_requested", + return_request_reference=_REQUEST_REFERENCE, + return_requested_at=_REQUESTED_AT, + return_dispatched_at=_DISPATCHED_AT, + return_delivered_at=_DELIVERED_AT, + ) + + +def test_return_verified_state_rejects_dispatch_evidence(): + with pytest.raises(ValueError, match="dispatch evidence"): + _build( + "return_request_verified", + return_request_reference=_REQUEST_REFERENCE, + return_requested_at=_REQUESTED_AT, + return_request_verified_at=_VERIFIED_AT, + return_due_at=_DUE_AT, + return_dispatched_at=_DISPATCHED_AT, + ) + + +def test_return_dispatched_state_rejects_delivery_evidence(): + with pytest.raises(ValueError, match="delivery evidence"): + _build( + "return_dispatched", + return_request_reference=_REQUEST_REFERENCE, + return_requested_at=_REQUESTED_AT, + return_request_verified_at=_VERIFIED_AT, + return_due_at=_DUE_AT, + return_dispatched_at=_DISPATCHED_AT, + return_delivered_at=_DELIVERED_AT, + ) diff --git a/packages/candidate-document-disposition/tests/test_statutory_retention_evidence.py b/packages/candidate-document-disposition/tests/test_statutory_retention_evidence.py new file mode 100644 index 000000000..9a200afe6 --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_statutory_retention_evidence.py @@ -0,0 +1,46 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from orgmetra_candidate_document_disposition import build_candidate_document_disposition + + +def _build(**overrides): + hiring_decision = datetime(2026, 9, 11, 12, 0, 0, tzinfo=timezone.utc) + kwargs = { + "tenant_record_id": "00000000-0000-4000-a000-000000000001", + "disposition_reference": "candidate_document_disposition:00000000-0000-4000-a000-000000000010", + "document_reference": "document:00000000-0000-4000-a000-000000000020", + "candidate_reference": "candidate_profile:00000000-0000-4000-a000-000000000030", + "hiring_decision_finalized_at": hiring_decision, + "return_eligibility": "eligible", + "state": "statutory_retained", + "retention_policy_version": "v2026_09", + "retention_policy_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "retention_anchor_event": "statutory_period_start", + "retain_until": None, + "legal_hold": False, + "purpose_code": "candidate_document_disposition", + "reason_code": "statutory_retention_expiry", + "evidence_version": 1, + "actor_reference": "actor:00000000-0000-4000-a000-000000000040", + } + kwargs.update(overrides) + return build_candidate_document_disposition(**kwargs) + + +@pytest.mark.parametrize( + "state", + ["statutory_retained", "statutory_retention_expired_destroyed"], +) +def test_statutory_retention_states_require_policy_deadline(state): + with pytest.raises(ValueError, match="statutory_retain_until"): + _build(state=state, statutory_retain_until=None) + + +def test_statutory_retention_state_keeps_explicit_policy_deadline(): + deadline = datetime(2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc) + + packet = _build(statutory_retain_until=deadline) + + assert packet.statutory_retain_until == deadline diff --git a/packages/candidate-document-disposition/uv.lock b/packages/candidate-document-disposition/uv.lock new file mode 100644 index 000000000..e606da214 --- /dev/null +++ b/packages/candidate-document-disposition/uv.lock @@ -0,0 +1,195 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" }, + { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" }, + { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z" }, + { url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z" }, + { url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z" }, + { url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z" }, + { url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z" }, + { url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z" }, + { url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z" }, + { url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z" }, + { url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "orgmetra-candidate-document-disposition" +version = "0.1.0" +source = { editable = "." } + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=5.0" }, +] +provides-extras = ["test"] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +]