From 9a0ca7e672fa71d6c48945757ef7df1dfe022d75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 19:06:08 +0900 Subject: [PATCH 01/36] feat(packaging): implement CandidateDocumentDisposition packet contract ADR 0303's core invariant as executable policy: a transport-neutral frozen dataclass carrying the disposition intent for a candidate document across talent_acquisition, people_core, and document_records boundaries. - CandidateDocumentDisposition dataclass with full field validation (UUID v4 references, controlled vocabularies for codes/states/events, timezone-aware timestamps, legal-hold vs. destroyed invariant) - canonical_json() and sha256_digest() for content-addressable transport - build_candidate_document_disposition() factory function - 84 tests, 100% statement + branch coverage --- .../pyproject.toml | 24 ++ .../__init__.py | 9 + .../packet.py | 261 +++++++++++++ .../tests/test_packet.py | 361 ++++++++++++++++++ .../candidate-document-disposition/uv.lock | 195 ++++++++++ 5 files changed, 850 insertions(+) create mode 100644 packages/candidate-document-disposition/pyproject.toml create mode 100644 packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/__init__.py create mode 100644 packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py create mode 100644 packages/candidate-document-disposition/tests/test_packet.py create mode 100644 packages/candidate-document-disposition/uv.lock 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..cedc90b31 --- /dev/null +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -0,0 +1,261 @@ +import json +import re +from dataclasses import dataclass +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_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", +}) +_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 _validate_operational_uuid(value: str, field_name: str) -> None: + 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 not isinstance(value, 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 not isinstance(value, 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 ( + not isinstance(value, 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 _canonical_timestamp(value: datetime) -> str: + if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamp must be timezone-aware") + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +@dataclass(frozen=True, slots=True, repr=False) +class CandidateDocumentDisposition: + """Immutable, PII-minimized candidate-document disposition packet. + + Carries the disposition intent for a candidate document across the + talent_acquisition, people_core, and document_records boundaries. + """ + + 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_dispatched_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 + + def __repr__(self) -> str: + return "CandidateDocumentDisposition()" + + def __post_init__(self) -> None: + _validate_operational_uuid(self.tenant_record_id, "tenant_record_id") + _validate_reference(self.disposition_reference, "candidate_document_disposition", "disposition_reference") + _validate_reference(self.document_reference, "document", "document_reference") + _validate_reference(self.candidate_reference, "candidate_profile", "candidate_reference") + _canonical_timestamp(self.hiring_decision_finalized_at) + _validate_code(self.return_eligibility, "return_eligibility") + if self.return_eligibility not in _ALLOWED_RETURN_ELIGIBILITY_CODES: + raise ValueError("return_eligibility must be an authorized disposition return code") + _validate_code(self.state, "state") + if self.state not in _ALLOWED_STATES: + raise ValueError("state must be an authorized disposition state") + _validate_code(self.retention_policy_version, "retention_policy_version") + _validate_digest(self.retention_policy_digest, "retention_policy_digest") + _validate_code(self.retention_anchor_event, "retention_anchor_event") + if self.retention_anchor_event not in _ALLOWED_RETENTION_ANCHOR_EVENTS: + raise ValueError("retention_anchor_event must be an authorized anchor event") + if self.retain_until is not None: + _canonical_timestamp(self.retain_until) + if self.claim_window_end is not None: + _canonical_timestamp(self.claim_window_end) + if self.return_dispatched_at is not None: + _canonical_timestamp(self.return_dispatched_at) + if self.statutory_retain_until is not None: + _canonical_timestamp(self.statutory_retain_until) + _validate_code(self.purpose_code, "purpose_code") + if self.purpose_code != _DISPOSITION_PURPOSE: + raise ValueError("purpose_code must remain candidate_document_disposition") + _validate_code(self.reason_code, "reason_code") + if self.reason_code not in _ALLOWED_REASON_CODES: + raise ValueError("reason_code must be an authorized disposition reason") + if type(self.evidence_version) is not int or not 1 <= self.evidence_version <= 2_147_483_647: + raise ValueError("evidence_version must be an integer from 1 through 2147483647") + _validate_reference(self.actor_reference, "actor", "actor_reference") + if self.previous_disposition_reference is not None: + _validate_reference( + self.previous_disposition_reference, + "candidate_document_disposition", + "previous_disposition_reference", + ) + if self.human_confirmation_required is not True: + raise ValueError("human confirmation is mandatory for document disposition") + if self.review_state != _REVIEW_STATE: + raise ValueError("review_state must remain requires_human_disposition_review") + if self.next_action != _NEXT_ACTION: + raise ValueError("next_action must remain the governed disposition instruction") + if self.legal_hold and self.state == "destroyed": + raise ValueError("a document under legal hold cannot be destroyed") + + def canonical_json(self) -> str: + 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_dispatched_at": _canonical_timestamp(self.return_dispatched_at) if self.return_dispatched_at is not None else None, + "return_eligibility": self.return_eligibility, + "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 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_dispatched_at: datetime | None = None, + statutory_retain_until: datetime | None = None, +) -> CandidateDocumentDisposition: + 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_dispatched_at=return_dispatched_at, + statutory_retain_until=statutory_retain_until, + ) 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..3a151e70d --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -0,0 +1,361 @@ +import json +from datetime import datetime, 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) + + +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) + 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_destroyed", + "talent_pool_retained", + "confirmed_hire_transitioned", + "statutory_retained", + "statutory_retention_expired_destroyed", + "legal_hold_suspended", + "destroyed", + ]) + def test_various_states(self, state): + p = _build(state=state) + 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_dispatched_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_dispatched_at"] is None + assert doc["statutory_retain_until"] is None + + def test_includes_set_optional_fields(self): + ts = datetime(2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc) + p = _build( + previous_disposition_reference="candidate_document_disposition:00000000-0000-4000-a000-000000000099", + claim_window_end=ts, + return_dispatched_at=ts, + statutory_retain_until=ts, + ) + 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_dispatched_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/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" }, +] From ae887b16b324d8b4e7bd7c3c79014e3851985e99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 20:00:22 +0900 Subject: [PATCH 02/36] test(talent): expose disposition lifecycle invariant gaps --- .../tests/test_packet_hardening.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 packages/candidate-document-disposition/tests/test_packet_hardening.py 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..22e34a300 --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -0,0 +1,57 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from orgmetra_candidate_document_disposition import 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) + + +@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) + + +@pytest.mark.parametrize( + "state", + ["return_destroyed", "statutory_retention_expired_destroyed", "destroyed"], +) +def test_legal_hold_rejects_every_destruction_state(state): + with pytest.raises(ValueError, match="legal hold"): + _build(legal_hold=True, state=state) + + +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" From 16027eeda1259c89048b2b3eb7a4190c09165527 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 20:03:27 +0900 Subject: [PATCH 03/36] fix(talent): enforce disposition lifecycle integrity --- .../packet.py | 224 ++++++++++++------ 1 file changed, 158 insertions(+), 66 deletions(-) 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 index cedc90b31..97145cc36 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -1,6 +1,6 @@ import json import re -from dataclasses import dataclass +from collections import namedtuple from datetime import datetime, timezone from hashlib import sha256 from uuid import UUID @@ -47,12 +47,47 @@ "talent_pool_expired", "statutory_period_start", }) +_DESTRUCTION_STATES = frozenset({ + "return_destroyed", + "statutory_retention_expired_destroyed", + "destroyed", +}) _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_dispatched_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: @@ -98,91 +133,135 @@ def _canonical_timestamp(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") -@dataclass(frozen=True, slots=True, repr=False) -class CandidateDocumentDisposition: - """Immutable, PII-minimized candidate-document disposition packet. +class CandidateDocumentDisposition(_CandidateDocumentDispositionTuple): + """Structurally immutable, PII-minimized candidate-document disposition packet. - Carries the disposition intent for a candidate document across the - talent_acquisition, people_core, and document_records boundaries. + Construction validates bounded lifecycle evidence before storing it 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. """ - 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_dispatched_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 - - def __repr__(self) -> str: - return "CandidateDocumentDisposition()" + __slots__ = () - def __post_init__(self) -> None: - _validate_operational_uuid(self.tenant_record_id, "tenant_record_id") - _validate_reference(self.disposition_reference, "candidate_document_disposition", "disposition_reference") - _validate_reference(self.document_reference, "document", "document_reference") - _validate_reference(self.candidate_reference, "candidate_profile", "candidate_reference") - _canonical_timestamp(self.hiring_decision_finalized_at) - _validate_code(self.return_eligibility, "return_eligibility") - if self.return_eligibility not in _ALLOWED_RETURN_ELIGIBILITY_CODES: + 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_dispatched_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") + _canonical_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(self.state, "state") - if self.state not in _ALLOWED_STATES: + _validate_code(state, "state") + if state not in _ALLOWED_STATES: raise ValueError("state must be an authorized disposition state") - _validate_code(self.retention_policy_version, "retention_policy_version") - _validate_digest(self.retention_policy_digest, "retention_policy_digest") - _validate_code(self.retention_anchor_event, "retention_anchor_event") - if self.retention_anchor_event not in _ALLOWED_RETENTION_ANCHOR_EVENTS: + _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 self.retain_until is not None: - _canonical_timestamp(self.retain_until) - if self.claim_window_end is not None: - _canonical_timestamp(self.claim_window_end) - if self.return_dispatched_at is not None: - _canonical_timestamp(self.return_dispatched_at) - if self.statutory_retain_until is not None: - _canonical_timestamp(self.statutory_retain_until) - _validate_code(self.purpose_code, "purpose_code") - if self.purpose_code != _DISPOSITION_PURPOSE: + if retain_until is not None: + _canonical_timestamp(retain_until) + if claim_window_end is not None: + _canonical_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 return_dispatched_at is not None: + _canonical_timestamp(return_dispatched_at) + if statutory_retain_until is not None: + _canonical_timestamp(statutory_retain_until) + _validate_code(purpose_code, "purpose_code") + if purpose_code != _DISPOSITION_PURPOSE: raise ValueError("purpose_code must remain candidate_document_disposition") - _validate_code(self.reason_code, "reason_code") - if self.reason_code not in _ALLOWED_REASON_CODES: + _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(self.evidence_version) is not int or not 1 <= self.evidence_version <= 2_147_483_647: + 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(self.actor_reference, "actor", "actor_reference") - if self.previous_disposition_reference is not None: + _validate_reference(actor_reference, "actor", "actor_reference") + if previous_disposition_reference is not None: _validate_reference( - self.previous_disposition_reference, + previous_disposition_reference, "candidate_document_disposition", "previous_disposition_reference", ) - if self.human_confirmation_required is not True: + if human_confirmation_required is not True: raise ValueError("human confirmation is mandatory for document disposition") - if self.review_state != _REVIEW_STATE: + if review_state != _REVIEW_STATE: raise ValueError("review_state must remain requires_human_disposition_review") - if self.next_action != _NEXT_ACTION: + if next_action != _NEXT_ACTION: raise ValueError("next_action must remain the governed disposition instruction") - if self.legal_hold and self.state == "destroyed": + 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_dispatched_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 that 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, @@ -211,6 +290,12 @@ def canonical_json(self) -> str: 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() @@ -237,6 +322,13 @@ def build_candidate_document_disposition( return_dispatched_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. + """ return CandidateDocumentDisposition( tenant_record_id=tenant_record_id, disposition_reference=disposition_reference, From b5b1e6180f649f5f87675dd1db238bd4a4ff3a0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 20:08:53 +0900 Subject: [PATCH 04/36] test(talent): expose retained runtime aliases --- .../tests/test_packet_hardening.py | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index 22e34a300..cbf278433 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo import pytest @@ -32,6 +32,43 @@ def _build(**overrides): return build_candidate_document_disposition(**kwargs) +class _MutableTimezone(tzinfo): + def __init__(self, offset_hours: int) -> None: + self.offset_hours = offset_hours + + def utcoffset(self, dt): + return timedelta(hours=self.offset_hours) + + def dst(self, dt): + return timedelta(0) + + +def test_timestamp_runtime_alias_cannot_change_digest_after_construction(): + caller_timezone = _MutableTimezone(0) + packet = _build( + hiring_decision_finalized_at=datetime( + 2026, + 9, + 11, + 12, + 0, + 0, + tzinfo=caller_timezone, + ) + ) + original_digest = packet.sha256_digest() + + caller_timezone.offset_hours = 9 + + assert packet.sha256_digest() == original_digest + assert packet.hiring_decision_finalized_at.tzinfo is not caller_timezone + + +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"] From f42bfcef0231be2333f098fa166973c5901d01fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 20:09:34 +0900 Subject: [PATCH 05/36] fix(talent): detach disposition runtime evidence --- .../packet.py | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) 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 index 97145cc36..af7ef198a 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -127,19 +127,24 @@ def _validate_reference(value: str, prefix: str, field_name: str) -> None: raise ValueError(message) -def _canonical_timestamp(value: datetime) -> str: - if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: +def _normalize_timestamp(value: datetime) -> datetime: + if type(value) is not datetime or value.tzinfo is None or value.utcoffset() is None: raise ValueError("timestamp must be timezone-aware") - return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + 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 before storing it 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. + 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__ = () @@ -178,7 +183,7 @@ def __new__( ) _validate_reference(document_reference, "document", "document_reference") _validate_reference(candidate_reference, "candidate_profile", "candidate_reference") - _canonical_timestamp(hiring_decision_finalized_at) + 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") @@ -191,15 +196,17 @@ def __new__( 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: - _canonical_timestamp(retain_until) + retain_until = _normalize_timestamp(retain_until) if claim_window_end is not None: - _canonical_timestamp(claim_window_end) + 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 return_dispatched_at is not None: - _canonical_timestamp(return_dispatched_at) + return_dispatched_at = _normalize_timestamp(return_dispatched_at) if statutory_retain_until is not None: - _canonical_timestamp(statutory_retain_until) + statutory_retain_until = _normalize_timestamp(statutory_retain_until) + if type(legal_hold) is not bool: + raise ValueError("legal_hold must be an exact boolean") _validate_code(purpose_code, "purpose_code") if purpose_code != _DISPOSITION_PURPOSE: raise ValueError("purpose_code must remain candidate_document_disposition") @@ -258,9 +265,9 @@ 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 that immutable state - with stable key ordering and UTC timestamps; it does not re-authorize a - disposition or execute a document lifecycle operation. + 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, @@ -327,7 +334,8 @@ def build_candidate_document_disposition( 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. + governed constant is invalid. Valid timestamps are detached into built-in + UTC datetimes before retention. """ return CandidateDocumentDisposition( tenant_record_id=tenant_record_id, From f589d1ef910b4477837863c5f20dfefb3a1b30f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:05:11 +0900 Subject: [PATCH 06/36] test(talent): reject executable text subtypes in disposition evidence --- .../tests/test_packet_hardening.py | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index cbf278433..a19e3be2c 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -2,7 +2,10 @@ import pytest -from orgmetra_candidate_document_disposition import build_candidate_document_disposition +from orgmetra_candidate_document_disposition import ( + CandidateDocumentDisposition, + build_candidate_document_disposition, +) def _kwargs() -> dict: @@ -43,6 +46,10 @@ def dst(self, dt): return timedelta(0) +class _TextSubtype(str): + pass + + def test_timestamp_runtime_alias_cannot_change_digest_after_construction(): caller_timezone = _MutableTimezone(0) packet = _build( @@ -92,3 +99,40 @@ def test_packet_state_cannot_be_rewritten_after_validation(): 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) From 76f7b60146d9e8a380ac8bc1fe11f9af08264110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:05:53 +0900 Subject: [PATCH 07/36] fix(talent): require inert built-in disposition text evidence --- .../packet.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 index af7ef198a..faa102329 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -91,6 +91,8 @@ 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: @@ -100,19 +102,19 @@ def _validate_operational_uuid(value: str, field_name: str) -> None: def _validate_code(value: str, field_name: str) -> None: - if not isinstance(value, str) or len(value) > 64 or not _CODE_PATTERN.fullmatch(value): + 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 not isinstance(value, str) or not _DIGEST_PATTERN.fullmatch(value): + 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 ( - not isinstance(value, str) + type(value) is not str or len(value) > 160 or not _REFERENCE_PATTERN.fullmatch(value) or not value.startswith(f"{prefix}:") @@ -224,8 +226,12 @@ def __new__( ) 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: From d41523b6b446089b9dbd94e3fcf328aa607faefc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:07:43 +0900 Subject: [PATCH 08/36] test(talent): reject executable nested timezone evidence --- .../tests/test_packet_hardening.py | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index a19e3be2c..db6b91841 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -35,12 +35,13 @@ def _build(**overrides): return build_candidate_document_disposition(**kwargs) -class _MutableTimezone(tzinfo): - def __init__(self, offset_hours: int) -> None: - self.offset_hours = offset_hours +class _ExecutableTimezone(tzinfo): + def __init__(self) -> None: + self.utcoffset_calls = 0 def utcoffset(self, dt): - return timedelta(hours=self.offset_hours) + self.utcoffset_calls += 1 + return timedelta(0) def dst(self, dt): return timedelta(0) @@ -50,25 +51,49 @@ class _TextSubtype(str): pass -def test_timestamp_runtime_alias_cannot_change_digest_after_construction(): - caller_timezone = _MutableTimezone(0) +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, - 12, + 21, 0, 0, - tzinfo=caller_timezone, + tzinfo=fixed_offset, ) ) - original_digest = packet.sha256_digest() - - caller_timezone.offset_hours = 9 - assert packet.sha256_digest() == original_digest - assert packet.hiring_decision_finalized_at.tzinfo is not caller_timezone + 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(): From 15d6dcacbaa6677bd9c87e7e9fb5d77b4b73d3e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:08:28 +0900 Subject: [PATCH 09/36] fix(talent): reject executable nested timezone evidence --- .../src/orgmetra_candidate_document_disposition/packet.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index faa102329..4c1ecb8d6 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -130,8 +130,8 @@ def _validate_reference(value: str, prefix: str, field_name: str) -> None: def _normalize_timestamp(value: datetime) -> datetime: - if type(value) is not datetime or value.tzinfo is None or value.utcoffset() is None: - raise ValueError("timestamp must be timezone-aware") + 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) From 1600812021e7a17ff9c016b22f3ac3705a889f64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:10:09 +0900 Subject: [PATCH 10/36] test(talent): require return dispatch evidence before destruction --- .../tests/test_packet_hardening.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index db6b91841..39d08c8b6 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -117,6 +117,32 @@ def test_legal_hold_rejects_every_destruction_state(state): _build(legal_hold=True, state=state) +@pytest.mark.parametrize("state", ["return_dispatched", "return_destroyed"]) +def test_return_dispatch_states_require_dispatch_timestamp(state): + with pytest.raises(ValueError, match="return_dispatched_at"): + _build(state=state, return_dispatched_at=None) + + +@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"] + with pytest.raises(ValueError, match="return_dispatched_at"): + _build( + state="return_dispatched", + return_dispatched_at=hiring_decision + offset, + ) + + +def test_return_destroyed_accepts_prior_dispatch_evidence(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + packet = _build( + state="return_destroyed", + return_dispatched_at=hiring_decision + timedelta(days=1), + ) + + assert packet.return_dispatched_at == hiring_decision + timedelta(days=1) + + def test_packet_state_cannot_be_rewritten_after_validation(): packet = _build(legal_hold=True) From 70ef86a1dfeafc955cf1e1408e9c97b3278c9b3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:11:07 +0900 Subject: [PATCH 11/36] fix(talent): bind return destruction to dispatch evidence --- .../src/orgmetra_candidate_document_disposition/packet.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 4c1ecb8d6..9452ef8c2 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -52,6 +52,10 @@ "statutory_retention_expired_destroyed", "destroyed", }) +_RETURN_DISPATCH_EVIDENCE_STATES = frozenset({ + "return_dispatched", + "return_destroyed", +}) _REVIEW_STATE = "requires_human_disposition_review" _NEXT_ACTION = ( "Within tenant_record_id, verify the disposition event against the authoritative " @@ -205,6 +209,10 @@ def __new__( raise ValueError("claim_window_end must be after hiring_decision_finalized_at") 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 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 statutory_retain_until is not None: statutory_retain_until = _normalize_timestamp(statutory_retain_until) if type(legal_hold) is not bool: From 61bf81afdcdf758fe5006885a4bb8b17e7f20b28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:12:02 +0900 Subject: [PATCH 12/36] test(talent): require return delivery evidence before destruction --- .../tests/test_packet_hardening.py | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index 39d08c8b6..9698f63f6 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -117,7 +117,10 @@ def test_legal_hold_rejects_every_destruction_state(state): _build(legal_hold=True, state=state) -@pytest.mark.parametrize("state", ["return_dispatched", "return_destroyed"]) +@pytest.mark.parametrize( + "state", + ["return_dispatched", "return_delivered", "return_destroyed"], +) def test_return_dispatch_states_require_dispatch_timestamp(state): with pytest.raises(ValueError, match="return_dispatched_at"): _build(state=state, return_dispatched_at=None) @@ -133,14 +136,40 @@ def test_return_dispatch_timestamp_must_follow_hiring_decision(offset): ) -def test_return_destroyed_accepts_prior_dispatch_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"] + with pytest.raises(ValueError, match="return_delivered_at"): + _build( + state=state, + return_dispatched_at=hiring_decision + timedelta(days=1), + return_delivered_at=None, + ) + + +def test_return_delivery_cannot_precede_dispatch(): + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + 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), + ) + + +def test_return_destroyed_accepts_prior_delivery_receipt(): hiring_decision = _kwargs()["hiring_decision_finalized_at"] + dispatched_at = hiring_decision + timedelta(days=1) + delivered_at = dispatched_at + timedelta(days=1) packet = _build( state="return_destroyed", - return_dispatched_at=hiring_decision + timedelta(days=1), + return_dispatched_at=dispatched_at, + return_delivered_at=delivered_at, ) - assert packet.return_dispatched_at == hiring_decision + timedelta(days=1) + assert packet.return_dispatched_at == dispatched_at + assert packet.return_delivered_at == delivered_at def test_packet_state_cannot_be_rewritten_after_validation(): From 4c1322b0619c4294e59bcd7755ee22fe83ef2215 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:12:52 +0900 Subject: [PATCH 13/36] fix(talent): require return delivery receipt before destruction --- .../packet.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 index 9452ef8c2..8861e63e8 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -31,6 +31,7 @@ "return_requested", "return_request_verified", "return_dispatched", + "return_delivered", "return_destroyed", "talent_pool_retained", "confirmed_hire_transitioned", @@ -54,6 +55,11 @@ }) _RETURN_DISPATCH_EVIDENCE_STATES = frozenset({ "return_dispatched", + "return_delivered", + "return_destroyed", +}) +_RETURN_DELIVERY_EVIDENCE_STATES = frozenset({ + "return_delivered", "return_destroyed", }) _REVIEW_STATE = "requires_human_disposition_review" @@ -82,6 +88,7 @@ "previous_disposition_reference", "claim_window_end", "return_dispatched_at", + "return_delivered_at", "statutory_retain_until", "human_confirmation_required", "review_state", @@ -176,6 +183,7 @@ def __new__( previous_disposition_reference: str | None = None, claim_window_end: 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, @@ -213,6 +221,14 @@ def __new__( raise ValueError("return_dispatched_at must be after hiring_decision_finalized_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 statutory_retain_until is not None: statutory_retain_until = _normalize_timestamp(statutory_retain_until) if type(legal_hold) is not bool: @@ -266,6 +282,7 @@ def __new__( previous_disposition_reference, claim_window_end, return_dispatched_at, + return_delivered_at, statutory_retain_until, human_confirmation_required, review_state, @@ -301,6 +318,7 @@ def canonical_json(self) -> str: "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_eligibility": self.return_eligibility, "review_state": self.review_state, @@ -341,6 +359,7 @@ def build_candidate_document_disposition( previous_disposition_reference: str | None = None, claim_window_end: 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. @@ -371,5 +390,6 @@ def build_candidate_document_disposition( previous_disposition_reference=previous_disposition_reference, claim_window_end=claim_window_end, return_dispatched_at=return_dispatched_at, + return_delivered_at=return_delivered_at, statutory_retain_until=statutory_retain_until, ) From 451283734675e4f006b322011cc027fcf3196dcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:13:51 +0900 Subject: [PATCH 14/36] test(talent): align state fixtures with return evidence invariants --- .../tests/test_packet.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py index 3a151e70d..8c3dd12b1 100644 --- a/packages/candidate-document-disposition/tests/test_packet.py +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -125,6 +125,7 @@ def test_various_return_eligibility_codes(self, code): "return_requested", "return_request_verified", "return_dispatched", + "return_delivered", "return_destroyed", "talent_pool_retained", "confirmed_hire_transitioned", @@ -134,7 +135,14 @@ def test_various_return_eligibility_codes(self, code): "destroyed", ]) def test_various_states(self, state): - p = _build(state=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 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", [ @@ -289,11 +297,13 @@ def test_contains_required_fields(self): def test_omits_none_optional_fields(self): p = _build(previous_disposition_reference=None, claim_window_end=None, - return_dispatched_at=None, statutory_retain_until=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_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): @@ -302,12 +312,14 @@ def test_includes_set_optional_fields(self): previous_disposition_reference="candidate_document_disposition:00000000-0000-4000-a000-000000000099", claim_window_end=ts, return_dispatched_at=ts, + return_delivered_at=ts, statutory_retain_until=ts, ) 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_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): From b1a2d15ebd30c09e9716479afe75d06185e2f49a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:14:29 +0900 Subject: [PATCH 15/36] test(talent): preserve legal-hold regression with valid return evidence --- .../tests/test_packet_hardening.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index 9698f63f6..47ae80742 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -113,8 +113,13 @@ def test_claim_window_must_follow_hiring_decision(offset): ["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["return_dispatched_at"] = hiring_decision + timedelta(days=1) + evidence["return_delivered_at"] = hiring_decision + timedelta(days=2) with pytest.raises(ValueError, match="legal hold"): - _build(legal_hold=True, state=state) + _build(legal_hold=True, state=state, **evidence) @pytest.mark.parametrize( From 676afec57044cde77779830bb17ce2767423960e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:19:32 +0900 Subject: [PATCH 16/36] test(talent): require verified return-request receipt chain --- .../tests/test_packet_hardening.py | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index 47ae80742..cbc4a602a 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -35,6 +35,15 @@ def _build(**overrides): return build_candidate_document_disposition(**kwargs) +def _verified_return_evidence() -> dict: + hiring_decision = _kwargs()["hiring_decision_finalized_at"] + return { + "return_request_reference": "candidate_document_return_request:00000000-0000-4000-a000-000000000050", + "return_requested_at": hiring_decision + timedelta(hours=1), + "return_request_verified_at": hiring_decision + timedelta(hours=2), + } + + class _ExecutableTimezone(tzinfo): def __init__(self) -> None: self.utcoffset_calls = 0 @@ -116,63 +125,142 @@ 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) 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_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) + 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): - with pytest.raises(ValueError, match="return_dispatched_at"): + with pytest.raises(ValueError, match="return_request_reference"): _build(state=state, return_dispatched_at=None) @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_prior_delivery_receipt(): +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_dispatched_at == dispatched_at assert packet.return_delivered_at == delivered_at From ec894437f7fa1ef855fe17e76c5abe2d8ff63f75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:20:31 +0900 Subject: [PATCH 17/36] fix(talent): bind return lifecycle to verified request receipt --- .../packet.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 index 8861e63e8..d3d4c5058 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -53,6 +53,19 @@ "statutory_retention_expired_destroyed", "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", @@ -87,6 +100,9 @@ "actor_reference", "previous_disposition_reference", "claim_window_end", + "return_request_reference", + "return_requested_at", + "return_request_verified_at", "return_dispatched_at", "return_delivered_at", "statutory_retain_until", @@ -182,6 +198,9 @@ def __new__( 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_dispatched_at: datetime | None = None, return_delivered_at: datetime | None = None, statutory_retain_until: datetime | None = None, @@ -215,10 +234,39 @@ def __new__( 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 return_request_reference is not None: + _validate_reference( + return_request_reference, + "candidate_document_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_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: @@ -281,6 +329,9 @@ def __new__( actor_reference, previous_disposition_reference, claim_window_end, + return_request_reference, + return_requested_at, + return_request_verified_at, return_dispatched_at, return_delivered_at, statutory_retain_until, @@ -321,6 +372,9 @@ def canonical_json(self) -> str: "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_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, @@ -358,6 +412,9 @@ def build_candidate_document_disposition( 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_dispatched_at: datetime | None = None, return_delivered_at: datetime | None = None, statutory_retain_until: datetime | None = None, @@ -389,6 +446,9 @@ def build_candidate_document_disposition( 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_dispatched_at=return_dispatched_at, return_delivered_at=return_delivered_at, statutory_retain_until=statutory_retain_until, From 65fc448c54a8297d9d2650693a8aaf4b6a3e3ae7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:21:26 +0900 Subject: [PATCH 18/36] test(talent): align packet fixtures with verified return chain --- .../tests/test_packet.py | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py index 8c3dd12b1..7d0c08dcb 100644 --- a/packages/candidate-document-disposition/tests/test_packet.py +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -37,6 +37,14 @@ def _build(**overrides) -> CandidateDocumentDisposition: return build_candidate_document_disposition(**kwargs) +def _return_request_evidence() -> dict: + return { + "return_request_reference": "candidate_document_return_request:00000000-0000-4000-a000-000000000050", + "return_requested_at": datetime(2026, 9, 11, 13, 0, 0, tzinfo=timezone.utc), + "return_request_verified_at": datetime(2026, 9, 11, 14, 0, 0, tzinfo=timezone.utc), + } + + class TestConstruction: def test_default_packet(self): p = _build() @@ -58,7 +66,11 @@ def test_optional_claim_window_end(self): 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) + 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): @@ -138,6 +150,23 @@ 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 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", + }: + evidence["return_request_verified_at"] = _return_request_evidence()["return_request_verified_at"] if state in {"return_dispatched", "return_delivered", "return_destroyed"}: evidence["return_dispatched_at"] = dispatch_at if state in {"return_delivered", "return_destroyed"}: @@ -296,28 +325,45 @@ def test_contains_required_fields(self): assert doc["legal_hold"] is False def test_omits_none_optional_fields(self): - p = _build(previous_disposition_reference=None, claim_window_end=None, - return_dispatched_at=None, return_delivered_at=None, - statutory_retain_until=None) + p = _build( + previous_disposition_reference=None, + claim_window_end=None, + return_request_reference=None, + return_requested_at=None, + return_request_verified_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_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): - ts = datetime(2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc) + 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=ts, - return_dispatched_at=ts, - return_delivered_at=ts, - statutory_retain_until=ts, + 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_dispatched_at"] is not None assert doc["return_delivered_at"] is not None assert doc["statutory_retain_until"] is not None From a4b80512d13b6e8376a050405f44f3e4eac9733f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:22:38 +0900 Subject: [PATCH 19/36] test(talent): use bounded return-request reference contract --- .../tests/test_packet_hardening.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index cbc4a602a..cbc806387 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -38,7 +38,7 @@ def _build(**overrides): def _verified_return_evidence() -> dict: hiring_decision = _kwargs()["hiring_decision_finalized_at"] return { - "return_request_reference": "candidate_document_return_request:00000000-0000-4000-a000-000000000050", + "return_request_reference": "candidate_return_request:00000000-0000-4000-a000-000000000050", "return_requested_at": hiring_decision + timedelta(hours=1), "return_request_verified_at": hiring_decision + timedelta(hours=2), } @@ -205,8 +205,9 @@ def test_dispatch_cannot_precede_verified_request(): ["return_dispatched", "return_delivered", "return_destroyed"], ) def test_return_dispatch_states_require_dispatch_timestamp(state): - with pytest.raises(ValueError, match="return_request_reference"): - _build(state=state, return_dispatched_at=None) + 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)]) From 252135ac1df1f001b9014ef12bc892984e31d74f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:23:24 +0900 Subject: [PATCH 20/36] fix(talent): shorten return-request reference namespace --- .../src/orgmetra_candidate_document_disposition/packet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d3d4c5058..6b7a9a255 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -237,7 +237,7 @@ def __new__( if return_request_reference is not None: _validate_reference( return_request_reference, - "candidate_document_return_request", + "candidate_return_request", "return_request_reference", ) if return_requested_at is not None: From 31cea071f88ea59416ab36536f71fd63a1790174 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:24:06 +0900 Subject: [PATCH 21/36] test(talent): align return fixtures to bounded request namespace --- packages/candidate-document-disposition/tests/test_packet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py index 7d0c08dcb..a7080f204 100644 --- a/packages/candidate-document-disposition/tests/test_packet.py +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -39,7 +39,7 @@ def _build(**overrides) -> CandidateDocumentDisposition: def _return_request_evidence() -> dict: return { - "return_request_reference": "candidate_document_return_request:00000000-0000-4000-a000-000000000050", + "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": datetime(2026, 9, 11, 14, 0, 0, tzinfo=timezone.utc), } From 3b767631202f2258863f624b71d58dcea5ca8d73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:25:10 +0900 Subject: [PATCH 22/36] test(talent): require claim-window and return-SLA evidence --- .../tests/test_packet_hardening.py | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index cbc806387..193a8e435 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -37,10 +37,12 @@ def _build(**overrides): 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": hiring_decision + timedelta(hours=2), + "return_request_verified_at": verified_at, + "return_due_at": verified_at + timedelta(days=14), } @@ -117,6 +119,22 @@ def test_claim_window_must_follow_hiring_decision(offset): _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"], @@ -188,10 +206,25 @@ def test_return_verification_cannot_precede_request(): _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", @@ -262,6 +295,7 @@ def test_return_destroyed_accepts_verified_request_and_delivery_receipt(): 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 From 2e2c42678f565dfd2e6307f154f51d8f22bd7f36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 21:25:59 +0900 Subject: [PATCH 23/36] fix(talent): carry policy-computed return SLA evidence --- .../packet.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 index 6b7a9a255..532ddf237 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -103,6 +103,7 @@ "return_request_reference", "return_requested_at", "return_request_verified_at", + "return_due_at", "return_dispatched_at", "return_delivered_at", "statutory_retain_until", @@ -201,6 +202,7 @@ def __new__( 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, @@ -234,6 +236,8 @@ def __new__( 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, @@ -261,6 +265,14 @@ def __new__( 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: @@ -332,6 +344,7 @@ def __new__( return_request_reference, return_requested_at, return_request_verified_at, + return_due_at, return_dispatched_at, return_delivered_at, statutory_retain_until, @@ -371,6 +384,7 @@ def canonical_json(self) -> str: "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, @@ -415,6 +429,7 @@ def build_candidate_document_disposition( 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, @@ -449,6 +464,7 @@ def build_candidate_document_disposition( 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, From 445b707fd7e1772360184e3daa1192f875cd5d65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 22:05:45 +0900 Subject: [PATCH 24/36] test(talent): reject future return-stage evidence --- .../test_return_state_evidence_envelope.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 packages/candidate-document-disposition/tests/test_return_state_evidence_envelope.py 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..2e5957b3d --- /dev/null +++ b/packages/candidate-document-disposition/tests/test_return_state_evidence_envelope.py @@ -0,0 +1,88 @@ +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, + ) + + +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_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, + ) From bda89e0e513e615c177e7d8eec4f199b6454f249 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 22:06:38 +0900 Subject: [PATCH 25/36] fix(talent): bind return evidence to lifecycle state --- .../packet.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 index 532ddf237..e6ad63743 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -75,6 +75,7 @@ "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 " @@ -289,6 +290,23 @@ def __new__( 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 + ): + 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 type(legal_hold) is not bool: From 2ecff0b8eca3c9daa26524284df54c515000b07f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 22:08:54 +0900 Subject: [PATCH 26/36] test(talent): align legacy fixtures with return SLA evidence --- .../tests/test_packet.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py index a7080f204..bb471ca5d 100644 --- a/packages/candidate-document-disposition/tests/test_packet.py +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -1,5 +1,5 @@ import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from hashlib import sha256 import pytest @@ -38,10 +38,12 @@ def _build(**overrides) -> CandidateDocumentDisposition: 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": datetime(2026, 9, 11, 14, 0, 0, tzinfo=timezone.utc), + "return_request_verified_at": verified_at, + "return_due_at": verified_at + timedelta(days=14), } @@ -166,7 +168,9 @@ def test_various_states(self, state): "return_delivered", "return_destroyed", }: - evidence["return_request_verified_at"] = _return_request_evidence()["return_request_verified_at"] + 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"}: @@ -331,6 +335,7 @@ def test_omits_none_optional_fields(self): 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, @@ -341,6 +346,7 @@ def test_omits_none_optional_fields(self): 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 @@ -364,6 +370,7 @@ def test_includes_set_optional_fields(self): 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 From bc16dda56329f6b5b8bb70481954362446e3ae0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 22:09:47 +0900 Subject: [PATCH 27/36] test(talent): carry claim-window evidence in state matrix --- packages/candidate-document-disposition/tests/test_packet.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py index bb471ca5d..a0bc66f28 100644 --- a/packages/candidate-document-disposition/tests/test_packet.py +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -152,6 +152,8 @@ 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 { "return_requested", "return_request_verified", From 887ab34271ed4b57938bf16fda0b361402d71427 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 22:12:01 +0900 Subject: [PATCH 28/36] test(talent): bind suspended state to active legal hold --- .../tests/test_legal_hold_state.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/candidate-document-disposition/tests/test_legal_hold_state.py 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 From 2fd12e5fea9d3edf2f45f4810ef98890891f2241 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 22:12:51 +0900 Subject: [PATCH 29/36] fix(talent): require active hold for suspended state --- .../src/orgmetra_candidate_document_disposition/packet.py | 2 ++ 1 file changed, 2 insertions(+) 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 index e6ad63743..7d9b55ef1 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -311,6 +311,8 @@ def __new__( statutory_retain_until = _normalize_timestamp(statutory_retain_until) 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") From 3af683009667f0c54f71c3e7ecc5dc6c83f9aaf3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 22:13:36 +0900 Subject: [PATCH 30/36] test(talent): align state matrix with legal-hold invariant --- packages/candidate-document-disposition/tests/test_packet.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py index a0bc66f28..969d953e0 100644 --- a/packages/candidate-document-disposition/tests/test_packet.py +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -154,6 +154,8 @@ def test_various_states(self, state): evidence = {} if state == "return_claim_window_open": evidence["claim_window_end"] = 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", From 0bb11541d1c4926cd3605f59123125a465d4fe82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 23:07:14 +0900 Subject: [PATCH 31/36] test(talent): require statutory retention deadline evidence --- .../test_statutory_retention_evidence.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/candidate-document-disposition/tests/test_statutory_retention_evidence.py 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 From 3c61995f5995d10acac170da3498de0ceeb513bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 23:08:35 +0900 Subject: [PATCH 32/36] fix(talent): bind statutory states to retention deadline --- .../src/orgmetra_candidate_document_disposition/packet.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 7d9b55ef1..84bbb4831 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -53,6 +53,10 @@ "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", @@ -309,6 +313,10 @@ def __new__( 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: From b0add8173ef5303c76d37fbeb02fe6e47fafa985 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 23:09:56 +0900 Subject: [PATCH 33/36] test(talent): align statutory-state fixture evidence --- packages/candidate-document-disposition/tests/test_packet.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/candidate-document-disposition/tests/test_packet.py b/packages/candidate-document-disposition/tests/test_packet.py index 969d953e0..4c847cac9 100644 --- a/packages/candidate-document-disposition/tests/test_packet.py +++ b/packages/candidate-document-disposition/tests/test_packet.py @@ -154,6 +154,10 @@ def test_various_states(self, state): 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 { From d94b4e783b8cb22895df28415574ad576c994069 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 00:05:00 +0900 Subject: [PATCH 34/36] test(talent): reject future return evidence before request --- .../test_return_state_evidence_envelope.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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 index 2e5957b3d..cb5afaed6 100644 --- a/packages/candidate-document-disposition/tests/test_return_state_evidence_envelope.py +++ b/packages/candidate-document-disposition/tests/test_return_state_evidence_envelope.py @@ -52,6 +52,26 @@ def test_pre_request_state_rejects_return_request_evidence(state): ) +@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( @@ -63,6 +83,27 @@ def test_return_requested_state_rejects_verification_evidence(): ) +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( From 294394ffff7585b1398f73f5c430a532c9533caf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 00:05:33 +0900 Subject: [PATCH 35/36] test(talent): reach legal-hold gate for statutory destruction --- .../tests/test_packet_hardening.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/candidate-document-disposition/tests/test_packet_hardening.py b/packages/candidate-document-disposition/tests/test_packet_hardening.py index 193a8e435..4917bf44d 100644 --- a/packages/candidate-document-disposition/tests/test_packet_hardening.py +++ b/packages/candidate-document-disposition/tests/test_packet_hardening.py @@ -146,6 +146,10 @@ def test_legal_hold_rejects_every_destruction_state(state): 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) From fdda3d8039e38904fa33dcc079d8b6e5d523fe43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 00:06:29 +0900 Subject: [PATCH 36/36] fix(talent): close pre-request return evidence envelope --- .../src/orgmetra_candidate_document_disposition/packet.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 84bbb4831..8c0a24bd5 100644 --- a/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py +++ b/packages/candidate-document-disposition/src/orgmetra_candidate_document_disposition/packet.py @@ -295,7 +295,12 @@ def __new__( 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 + 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 (