diff --git a/.github/workflows/offer-to-hire-close-quality.yml b/.github/workflows/offer-to-hire-close-quality.yml new file mode 100644 index 000000000..5fbe5e789 --- /dev/null +++ b/.github/workflows/offer-to-hire-close-quality.yml @@ -0,0 +1,94 @@ +name: Offer To Hire Close Quality + +on: + pull_request: + paths: + - "services/people-api/pyproject.toml" + - "services/people-api/src/orgmetra_people_api/__init__.py" + - "services/people-api/src/orgmetra_people_api/offer_close.py" + - "services/people-api/src/orgmetra_people_api/hire.py" + - "services/people-api/src/orgmetra_people_api/auth.py" + - "services/people-api/src/orgmetra_people_api/authorization.py" + - "services/people-api/tests/test_offer_to_hire_*.py" + - "services/people-api/README.md" + - "services/people-api/CHANGELOG.md" + - "packages/candidate-offer-response/**" + - "packages/keyverse-adapter/**" + - "packages/hris-kernel/**" + - "docs/traceability/offer-to-hire-close.md" + - "docs/doctoring/offer-to-hire-close-references.md" + - ".github/requirements/foundation-test.txt" + - ".github/workflows/offer-to-hire-close-quality.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: offer-to-hire-close-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + contract: + name: Offer acceptance to confirmed-hire contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Prove exact candidate checkout + env: + ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" + - name: Set up exact Python runtime + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14.7" + check-latest: false + - name: Install reviewed test toolchain + run: | + python -m pip install --require-hashes --no-deps --only-binary=:all: -r .github/requirements/foundation-test.txt + python -m pip check + - name: Compile governed boundary + run: | + python -m compileall -q \ + services/people-api/src \ + packages/keyverse-adapter/src \ + packages/candidate-offer-response/src \ + packages/hris-kernel/src \ + services/people-api/tests/test_offer_to_hire_close.py \ + services/people-api/tests/test_offer_to_hire_authorization_order.py + - name: Test exact offer-to-hire boundary with 100% owned coverage + env: + PYTHONPATH: services/people-api/src:packages/keyverse-adapter/src:packages/candidate-offer-response/src:packages/hris-kernel/src + COVERAGE_FILE: /tmp/orgmetra-offer-to-hire-close.coverage + run: >- + python -m pytest + -o 'addopts=--cov=orgmetra_people_api.offer_close --cov-branch --cov-report=term-missing --cov-fail-under=100' + services/people-api/tests/test_offer_to_hire_close.py + services/people-api/tests/test_offer_to_hire_authorization_order.py + - name: Require public export and declared candidate-response dependency + env: + PYTHONPATH: services/people-api/src:packages/keyverse-adapter/src:packages/candidate-offer-response/src:packages/hris-kernel/src + run: | + python - <<'PY' + import orgmetra_people_api + from orgmetra_people_api import close_accepted_offer_to_hire + + assert close_accepted_offer_to_hire is orgmetra_people_api.close_accepted_offer_to_hire + pyproject = open("services/people-api/pyproject.toml", encoding="utf-8").read() + assert '"orgmetra-candidate-offer-response==0.1.0"' in pyproject + PY + - name: Require buyer-readable governance documentation + run: | + grep -F 'CandidateOfferHireAuthority' services/people-api/README.md + grep -F 'not an autonomous or model-derived employment decision' docs/traceability/offer-to-hire-close.md + grep -F 'U.S. Equal Employment Opportunity Commission' docs/doctoring/offer-to-hire-close-references.md + grep -F 'close_accepted_offer_to_hire' services/people-api/CHANGELOG.md + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/docs/doctoring/offer-to-hire-close-references.md b/docs/doctoring/offer-to-hire-close-references.md new file mode 100644 index 000000000..15a874436 --- /dev/null +++ b/docs/doctoring/offer-to-hire-close-references.md @@ -0,0 +1,24 @@ +# Offer-to-hire close references + +Reviewed 2026-08-24. These sources inform the governance principle that a candidate response and prior selection evidence remain evidence inputs to an accountable employer decision process; they do not turn candidate acknowledgement, an assessment score, or an automated signal into employment authority by themselves. + +## Primary / authoritative sources + +U.S. Equal Employment Opportunity Commission. (2007, December 1). *Employment tests and selection procedures*. https://www.eeoc.gov/laws/guidance/employment-tests-and-selection-procedures + +- The EEOC identifies employment tests and other selection procedures as subject to federal anti-discrimination law and directs employers to ensure that selection procedures are properly validated for the positions and purposes for which they are used. +- Orgmetra therefore preserves the existing accountable selection-decision authorization boundary rather than allowing an offer-response packet to bypass it. + +U.S. Equal Employment Opportunity Commission. (n.d.). *Regulations and guidelines*. Retrieved August 24, 2026, from https://www.eeoc.gov/regulations-and-guidelines + +- The current EEOC regulations index identifies 29 C.F.R. Part 1607 as the Uniform Guidelines on Employee Selection Procedures. +- This repository treats the Uniform Guidelines as a governing selection-procedure reference, not as a software certification claim. + +Society for Industrial and Organizational Psychology. (2023, January 21). *Considerations and recommendations for the validation and use of AI-based assessments for employee selection*. https://www.siop.org/wp-content/uploads/legacy/SIOP%20Considerations%20and%20Recommendations%20for%20the%20Validation%20and%20Use%20of%20AI-Based%20Assessments%20for%20Employee%20Selection%20010323.pdf + +- SIOP states that AI-based assessments used for hiring and promotion should meet the same scrutiny and standards applied to traditional employment tests and emphasizes documentation for verification and auditing. +- #108 does not introduce an AI decision path. The reference supports the broader Orgmetra rule that evidence provenance and accountable human/employer authority remain distinct from any evidence-generating mechanism. + +## Repository interpretation + +The cited materials do not prescribe Orgmetra's exact API shape. The software contract is an engineering control derived from the product's high-impact-decision requirements: candidate acceptance is necessary evidence for closing an accepted offer, while the authoritative candidate/offer/selection mapping, purpose-bound authorization, and immutable HR mutation remain separate controlled boundaries. diff --git a/docs/traceability/offer-to-hire-close.md b/docs/traceability/offer-to-hire-close.md new file mode 100644 index 000000000..80e775188 --- /dev/null +++ b/docs/traceability/offer-to-hire-close.md @@ -0,0 +1,36 @@ +# Offer-to-hire close traceability + +## Status + +- **Protected-main truth:** `develop@9e3e4847510e1e612b48474ba42b177b8ed824df` already contains the governed `accept_confirmed_hire(...)` path. It authorizes one immutable `selection_decision` with purpose-bound policy before an injected mutation port may materialize Person, Employment, candidate-to-worker conversion, audit, and outbox facts. +- **Parent active PR:** #80 owns `CandidateOfferResponsePacket`, a value-minimized candidate-originated `offer_accepted` / `offer_declined` evidence packet that is explicitly `not_authorized_to_hire`. +- **This active stacked PR:** #108 first requires the authenticated principal to be purpose-bound to the exact hire selection decision, then connects an intact `offer_accepted` packet to the existing confirmed-hire path only after an authoritative host re-resolves candidate identity, candidate profile, exact offer approval/terms provenance, response identity, and the immutable selection decision. +- **Not shipped:** #108 is not protected-main truth and remains dependency-constrained on #80. Its checks/reviews must not be transferred from #80; after #80 integrates, this lane must retarget to fresh `develop` and obtain fresh exact-head People/Foundation/SAST/Security/Recovery evidence. + +## Safety and authority contract + +| Concern | Executable boundary | +|---|---| +| Authorization before sensitive resolution | `close_accepted_offer_to_hire(...)` purpose-authorizes the exact `selection_decision` and `candidate_worker_conversion` operation before invoking the candidate/offer authority resolver. Wrong-purpose, wrong-scope, or foreign-tenant callers therefore cannot use protected candidate/offer resolution as an oracle. | +| Candidate decline | With a valid hire authorization context, `offer_declined` stops before the authoritative resolver or hire mutation port. | +| Candidate response is not hire authority | The bridge accepts only canonical `CandidateOfferResponsePacket` evidence, then requires `CandidateOfferHireAuthority.verify_offer_acceptance(...)`; it never writes HR facts directly. | +| Tenant isolation | Candidate-response tenant must equal the `HireAcceptanceCommand` tenant, and the returned authority evidence must bind the same tenant. | +| Candidate linkage | The authority resolves the packet's opaque candidate-profile reference to the exact `candidate_profile_id` in the hire command. | +| Selection-decision linkage | The authority must bind the exact immutable `selection_decision_id` consumed by the protected confirmed-hire authorization path. | +| Offer provenance | Response SHA-256, offer-approval digest, offer-terms digest, and external candidate actor must exactly match the snapshotted candidate response. | +| Concurrent / post-construction response mutation | Candidate-response canonical evidence is verified before authority work and revalidated after it; a mutated packet fails closed before hire materialization. | +| Authority runtime integrity | `CandidateOfferHireVerification` is copied into an exact built-in/runtime-owned verification object and all UUID, digest, actor, and authority-reference fields are revalidated before use. | +| High-impact human/authorization boundary | The bridge delegates consequential mutation to existing `accept_confirmed_hire(...)`, which independently reauthorizes purpose, operation scope, authorized field set, authenticated principal, and exact selection-decision target immediately before persistence. | +| PII minimization | The bridge carries only correlation identifiers, evidence digests, candidate actor correlation, and the already-existing hire command. It does not duplicate offer compensation, candidate profile values, assessment scores, or free-form candidate text. | + +## Executable evidence + +`services/people-api/tests/test_offer_to_hire_close.py` defines regressions for decline-before-resolution, valid accepted-offer delegation, tenant/candidate/selection mismatches, evidence digest/actor mismatch, response mutation before and during authority work, runtime-type forgery, malformed authority evidence, and post-construction authority-evidence rewriting. + +`services/people-api/tests/test_offer_to_hire_authorization_order.py` independently proves that a denied purpose-bound request cannot invoke the protected candidate/offer authority resolver or hire persistence boundary. + +`.github/workflows/offer-to-hire-close-quality.yml` is the dedicated exact-head gate for this slice. The current stacked PR must remain Draft whenever this gate or any applicable integration gate is absent, queued, pending, cancelled, skipped, neutral, failed, stale, or otherwise non-terminal. A stack-local GREEN result would still not authorize merge before #80 integrates and the descendant is revalidated against fresh protected `develop`. + +## Ownership + +Orgmetra owns this bridge and the existing confirmed-hire application boundary. Keyverse remains the read-only identity owner through its published identity contract; #108 does not mutate Keyverse or query foreign application tables. Offer response remains candidate evidence, not an autonomous or model-derived employment decision. diff --git a/services/people-api/CHANGELOG.md b/services/people-api/CHANGELOG.md new file mode 100644 index 000000000..4365a2af8 --- /dev/null +++ b/services/people-api/CHANGELOG.md @@ -0,0 +1,15 @@ +# People API changelog + +## Unreleased + +### Added + +- Add `close_accepted_offer_to_hire(...)` as a governed bridge from an intact candidate `offer_accepted` evidence packet to the existing authoritative confirmed-hire path. +- Add `CandidateOfferHireAuthority` and redacted `CandidateOfferHireVerification` contracts so candidate identity, candidate profile, exact offer provenance, and immutable selection decision are re-resolved before any hire materialization. +- Add fail-closed regressions for decline handling, tenant/candidate/selection mismatch, evidence mismatch, response tampering, authority-evidence runtime forgery, post-construction rewriting, and authorization ordering. + +### Security / governance + +- Candidate offer response remains necessary but non-authorizing evidence; it cannot directly create Person, Employment, or candidate-to-worker facts. +- Require purpose-bound authorization for the exact `materialize_worker` selection decision before protected candidate/offer authority resolution, then independently reauthorize through existing `accept_confirmed_hire(...)` immediately before persistence. +- This change is stacked on candidate-offer-response PR #80 and is not protected-main truth until its parent integrates and this descendant is freshly revalidated against protected `develop`. diff --git a/services/people-api/README.md b/services/people-api/README.md index 548a83446..dc2111e39 100644 --- a/services/people-api/README.md +++ b/services/people-api/README.md @@ -14,6 +14,8 @@ The People API quality workflow is part of this contract and must run for pull r `HireAcceptanceAsgiApp` exposes confirmed-hire materialization as `POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire`. Authentication and tenant binding occur before request-body parsing, so an unauthenticated or foreign-tenant caller cannot use body parsing or command construction as an oracle. Authenticated requests then pass the validated `Idempotency-Key`, content-type, JSON/schema, authorization, and governed command checks under a 64 KiB cumulative request-body limit, at most 1024 ASGI request frames, and 128 nested JSON containers below the top-level command object. `PostgresHireAcceptancePort` acquires a transaction-scoped lock for the tenant/route/key before it persists Person, Employment, `candidate_worker_conversion_record`, governed audit/outbox evidence, and `people_mutation_idempotency_record` in one tenant-bound transaction. An exact retry returns the first committed person/employment/conversion identities without repeating necessary PII, audit, or outbox writes; reusing the key for changed command semantics fails closed. The legacy `candidate_worker_link` write path is not used. +`close_accepted_offer_to_hire()` is the governed application bridge between candidate-originated offer response evidence and that existing confirmed-hire boundary. Before it resolves candidate identity or offer provenance, it requires the authenticated principal to pass the same purpose-bound `materialize_worker` authorization for the exact immutable selection decision, so a wrong-purpose, wrong-scope, or foreign-tenant caller cannot use protected offer/candidate resolution as an oracle. With a valid hire authorization context, `offer_declined` stops before the authority resolver or People mutation is called. An `offer_accepted` packet is still **not** hire authority: the bridge snapshots its canonical evidence, then requires an injected `CandidateOfferHireAuthority` to re-resolve the external candidate actor, opaque candidate-profile reference, exact offer-approval and offer-terms provenance, and their mapping to the immutable `selection_decision_id` in the `HireAcceptanceCommand`. The returned `CandidateOfferHireVerification` must exactly match tenant, candidate profile, selection decision, response digest, offer digests, and candidate actor; the candidate packet is revalidated after authority work to detect concurrent or post-construction tampering. Only after those checks does the bridge call `accept_confirmed_hire()`, which independently repeats the existing purpose-bound selection-decision authorization immediately before the mutation port can materialize worker truth. This bridge does not duplicate candidate PII, compensation values, assessment scores, or free-form response text, and it never queries a foreign service's application tables. + `PeopleMutationAsgiApp` exposes the governed People mutation API as `POST /v1/employment-records`, `POST /v1/position-records`, and `POST /v1/assignment-records`. Each command requires an idempotency key, tenant/actor/purpose headers, a non-blank accountable decision reason, human confirmation, and versioned evidence. The HTTP boundary enforces the exact OpenAPI evidence-object shape and cardinality, rejects additional fields and duplicate evidence items, and canonicalizes the complete reference/version set independent of array order. It first derives a PII-minimized `evidence_set_v1:` identity and then binds that identity together with the exact validated decision reason into `governance_evidence_v1:`. The free-text reason and raw evidence references are not copied into the portable audit envelope, but any reason/reference/version drift changes the governance binding, the immutable audit correlation evidence, and the durable idempotency command digest. A caller therefore cannot reuse the same key after silently changing the high-impact rationale and receive an incorrect replay. The validated `Idempotency-Key` is copied onto the application command and into `PostgresPeopleMutationPort`. Employment and assignment writes require a current `candidate_worker_conversion_record` (`recorded_to IS NULL`) and reuse `orgmetra_hris_kernel` exclusivity and assignment-coverage checks before the port inserts the authoritative fact, calls `record_audit_outbox_event`, and stores `people_mutation_idempotency_record` in the same transaction. A matching retry returns the first committed identity without a second HRIS, audit, or outbox fact. Successful responses contain only opaque record identifiers. The superseded persistence model must not be restored, and the service must not use direct cross-service application-table SQL. diff --git a/services/people-api/pyproject.toml b/services/people-api/pyproject.toml index 3f228e11c..32f4d3def 100644 --- a/services/people-api/pyproject.toml +++ b/services/people-api/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.11" license = { text = "Apache-2.0" } authors = [{ name = "ContextualWisdomLab" }] dependencies = [ + "orgmetra-candidate-offer-response==0.1.0", "orgmetra-hris-kernel==0.1.0", "orgmetra-keyverse-adapter==0.1.0", ] diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index b043bed33..6425dd2b3 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -1,4 +1,4 @@ -"""Request-edge, governed read, confirmed-hire, and People mutation contracts.""" +"""Request-edge, governed read, confirmed-hire, offer-close, and People mutation contracts.""" from orgmetra_people_api.auth import ( AuthenticatedPrincipal, @@ -32,6 +32,12 @@ create_employment_record, create_position_record, ) +from orgmetra_people_api.offer_close import ( + CandidateOfferHireAuthority, + CandidateOfferHireVerification, + OfferToHireIntegrityError, + close_accepted_offer_to_hire, +) from orgmetra_people_api.people import ( AuthorizedWorkerPeopleView, PeopleReadPort, @@ -48,12 +54,15 @@ "AuthenticatedPrincipal", "AuthenticationFailed", "AuthorizedWorkerPeopleView", + "CandidateOfferHireAuthority", + "CandidateOfferHireVerification", "HireAcceptanceCommand", "HireAcceptancePort", "HireAcceptanceResult", "HireAcceptanceAsgiApp", "HireDecisionIntegrityError", "HireDecisionNotFound", + "OfferToHireIntegrityError", "PeopleAsgiApp", "PeopleMutationAsgiApp", "PeopleMutationIntegrityError", @@ -75,6 +84,7 @@ "WorkerPeopleRecord", "accept_confirmed_hire", "authorize_resource_fields", + "close_accepted_offer_to_hire", "create_assignment_record", "create_employment_record", "create_position_record", diff --git a/services/people-api/src/orgmetra_people_api/offer_close.py b/services/people-api/src/orgmetra_people_api/offer_close.py new file mode 100644 index 000000000..f711d5dbd --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/offer_close.py @@ -0,0 +1,266 @@ +"""Connect candidate acceptance evidence to authoritative confirmed-hire materialization. + +Candidate response evidence is a necessary candidate-originated fact, never hire +authority. This boundary validates the supplied response envelope, verifies that the +authenticated caller is purpose-bound to materialize the exact selection decision before +protected candidate/offer resolution, asks an injected authoritative host to resolve the +response, verifies the returned scope, and delegates to the existing confirmed-hire path. +No candidate PII or compensation value is copied into this orchestration boundary. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import json +import re +from typing import Protocol, runtime_checkable +from uuid import UUID + +from orgmetra_candidate_offer_response import CandidateOfferResponsePacket +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy + +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.authorization import authorize_resource_fields +from orgmetra_people_api.hire import ( + HireAcceptanceCommand, + HireAcceptancePort, + HireAcceptanceResult, + accept_confirmed_hire, +) + +_MAX_UUID_INT = (1 << 128) - 1 +_DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_REFERENCE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$") +_HIRE_MUTATION_FIELDS = frozenset({"candidate_worker_conversion"}) + + +class OfferToHireIntegrityError(RuntimeError): + """Indicate that offer-response evidence cannot safely authorize hire orchestration.""" + + +def _validate_operational_uuid(value: object, field_name: str) -> None: + """Require an exact non-sentinel UUID from authoritative Orgmetra resolution.""" + if type(value) is not UUID or value.int in (0, _MAX_UUID_INT): + raise ValueError(f"{field_name} must be an exact operational UUID") + + +def _validate_digest(value: object, field_name: str) -> None: + """Require exact built-in lowercase SHA-256 hexadecimal evidence.""" + if type(value) is not str or _DIGEST_PATTERN.fullmatch(value) is None: + raise ValueError(f"{field_name} must be lowercase SHA-256 hex") + + +def _validate_candidate_actor(value: object) -> None: + """Require the bounded external candidate subject reviewed by the response contract.""" + if ( + type(value) is not str + or len(value) > 288 + or _REFERENCE_PATTERN.fullmatch(value) is None + or not value.startswith("candidate:") + ): + raise ValueError("candidate_actor_reference must be a bounded candidate: opaque reference") + + +def _validate_authority_reference(value: object) -> None: + """Require one Orgmetra-owned opaque UUIDv4 authority evidence reference.""" + message = "authority_evidence_reference must be an opaque offer_hire_verification: UUIDv4 reference" + if ( + type(value) is not str + or len(value) > 160 + or _REFERENCE_PATTERN.fullmatch(value) is None + or not value.startswith("offer_hire_verification:") + ): + raise ValueError(message) + suffix = value.split(":", 1)[1] + try: + parsed = UUID(suffix) + except (ValueError, AttributeError, TypeError) as error: + raise ValueError(message) from error + if str(parsed) != suffix or parsed.version != 4 or parsed.int in (0, _MAX_UUID_INT): + raise ValueError(message) + + +@dataclass(frozen=True, slots=True, repr=False) +class CandidateOfferHireVerification: + """PII-minimized evidence that an authoritative host resolved one accepted offer to hire scope.""" + + tenant_record_id: UUID + candidate_profile_id: UUID + selection_decision_id: UUID + offer_response_digest: str + offer_approval_digest: str + offer_terms_digest: str + candidate_actor_reference: str + authority_evidence_reference: str + authority_evidence_digest: str + + def __repr__(self) -> str: + """Avoid emitting candidate, selection, offer, or authority correlation in routine logs.""" + return "CandidateOfferHireVerification()" + + def __post_init__(self) -> None: + """Reject malformed authoritative evidence before the hire path can consume it.""" + self.validate_live() + + def validate_live(self) -> None: + """Revalidate fields so post-construction rewriting cannot cross the trust boundary.""" + _validate_operational_uuid(self.tenant_record_id, "tenant_record_id") + _validate_operational_uuid(self.candidate_profile_id, "candidate_profile_id") + _validate_operational_uuid(self.selection_decision_id, "selection_decision_id") + _validate_digest(self.offer_response_digest, "offer_response_digest") + _validate_digest(self.offer_approval_digest, "offer_approval_digest") + _validate_digest(self.offer_terms_digest, "offer_terms_digest") + _validate_candidate_actor(self.candidate_actor_reference) + _validate_authority_reference(self.authority_evidence_reference) + _validate_digest(self.authority_evidence_digest, "authority_evidence_digest") + + +@runtime_checkable +class CandidateOfferHireAuthority(Protocol): + """Resolve one candidate response to exact authoritative candidate and selection scope.""" + + def verify_offer_acceptance( + self, + *, + tenant_record_id: str, + candidate_profile_reference: str, + selection_decision_id: UUID, + offer_response_reference: str, + offer_response_digest: str, + candidate_actor_reference: str, + identity_resolution_reference: str, + identity_resolution_digest: str, + offer_approval_reference: str, + offer_approval_digest: str, + offer_terms_reference: str, + offer_terms_digest: str, + responded_at: str, + ) -> CandidateOfferHireVerification: + """Return exact-scope evidence only after authoritative identity/offer resolution succeeds.""" + + +def _snapshot_response(response: CandidateOfferResponsePacket) -> tuple[str, dict[str, object]]: + """Freeze one verified candidate-response representation before authoritative host work.""" + if type(response) is not CandidateOfferResponsePacket: + raise TypeError("response must be the exact CandidateOfferResponsePacket runtime type") + try: + canonical_json = response.canonical_json() + except (KeyError, ValueError) as error: + raise OfferToHireIntegrityError("candidate offer response evidence is not intact") from error + payload = json.loads(canonical_json) + return sha256(canonical_json.encode("utf-8")).hexdigest(), payload + + +def _snapshot_verification(value: CandidateOfferHireVerification) -> CandidateOfferHireVerification: + """Copy and revalidate exact authority evidence before comparing it with requested scope.""" + if type(value) is not CandidateOfferHireVerification: + raise TypeError("authority must return the exact CandidateOfferHireVerification runtime type") + return CandidateOfferHireVerification( + tenant_record_id=value.tenant_record_id, + candidate_profile_id=value.candidate_profile_id, + selection_decision_id=value.selection_decision_id, + offer_response_digest=value.offer_response_digest, + offer_approval_digest=value.offer_approval_digest, + offer_terms_digest=value.offer_terms_digest, + candidate_actor_reference=value.candidate_actor_reference, + authority_evidence_reference=value.authority_evidence_reference, + authority_evidence_digest=value.authority_evidence_digest, + ) + + +def close_accepted_offer_to_hire( + *, + response: CandidateOfferResponsePacket, + principal: AuthenticatedPrincipal, + command: HireAcceptanceCommand, + purpose_code: str, + policy: PurposeBoundAccessPolicy, + authority: CandidateOfferHireAuthority, + mutation_port: HireAcceptancePort, +) -> HireAcceptanceResult: + """Require response integrity, authorization, and exact mapping before hire mutation. + + The candidate response cannot authorize employment creation by itself. The supplied packet + is first validated as immutable candidate-originated evidence and checked against the hire + command tenant. Before candidate identity or offer provenance is resolved, the authenticated + principal must be purpose-bound to materialize the exact immutable selection decision. The + injected authority then re-resolves candidate and offer scope. Only after those checks does + this function invoke the existing confirmed-hire service, which independently reauthorizes + the same selection decision immediately before persistence. + """ + if type(command) is not HireAcceptanceCommand: + raise TypeError("command must be the exact HireAcceptanceCommand runtime type") + if not isinstance(authority, CandidateOfferHireAuthority): + raise TypeError("authority must implement CandidateOfferHireAuthority") + + response_digest, payload = _snapshot_response(response) + if payload.get("response_code") != "offer_accepted": + raise OfferToHireIntegrityError("candidate offer response must be accepted before hire orchestration") + if payload.get("tenant_record_id") != str(command.tenant_record_id): + raise OfferToHireIntegrityError("candidate response tenant does not match the hire command") + + authorize_resource_fields( + principal=principal, + tenant_record_id=command.tenant_record_id, + resource_tenant_record_id=command.tenant_record_id, + resource_reference=f"selection_decision:{command.selection_decision_id.hex}", + purpose_code=purpose_code, + operation_code="materialize_worker", + resource_kind="selection_decision", + requested_fields=_HIRE_MUTATION_FIELDS, + policy=policy, + ) + + verification = authority.verify_offer_acceptance( + tenant_record_id=str(payload["tenant_record_id"]), + candidate_profile_reference=str(payload["candidate_profile_reference"]), + selection_decision_id=command.selection_decision_id, + offer_response_reference=str(payload["offer_response_reference"]), + offer_response_digest=response_digest, + candidate_actor_reference=str(payload["candidate_actor_reference"]), + identity_resolution_reference=str(payload["identity_resolution_reference"]), + identity_resolution_digest=str(payload["identity_resolution_digest"]), + offer_approval_reference=str(payload["offer_approval_reference"]), + offer_approval_digest=str(payload["offer_approval_digest"]), + offer_terms_reference=str(payload["offer_terms_reference"]), + offer_terms_digest=str(payload["offer_terms_digest"]), + responded_at=str(payload["responded_at"]), + ) + verified = _snapshot_verification(verification) + + if verified.tenant_record_id != command.tenant_record_id: + raise OfferToHireIntegrityError("authority tenant does not match the hire command") + if verified.candidate_profile_id != command.candidate_profile_id: + raise OfferToHireIntegrityError("authority candidate profile does not match the hire command") + if verified.selection_decision_id != command.selection_decision_id: + raise OfferToHireIntegrityError("authority selection decision does not match the hire command") + expected_evidence = ( + response_digest, + payload["offer_approval_digest"], + payload["offer_terms_digest"], + payload["candidate_actor_reference"], + ) + verified_evidence = ( + verified.offer_response_digest, + verified.offer_approval_digest, + verified.offer_terms_digest, + verified.candidate_actor_reference, + ) + if verified_evidence != expected_evidence: + raise OfferToHireIntegrityError("authority evidence does not match the accepted offer response") + + try: + response.canonical_json() + except (KeyError, ValueError) as error: + raise OfferToHireIntegrityError( + "candidate offer response changed during authoritative verification" + ) from error + + return accept_confirmed_hire( + principal=principal, + command=command, + purpose_code=purpose_code, + policy=policy, + mutation_port=mutation_port, + ) diff --git a/services/people-api/tests/test_offer_to_hire_authorization_order.py b/services/people-api/tests/test_offer_to_hire_authorization_order.py new file mode 100644 index 000000000..9c1e9c355 --- /dev/null +++ b/services/people-api/tests/test_offer_to_hire_authorization_order.py @@ -0,0 +1,122 @@ +"""Regression for authorization ordering in accepted-offer hire orchestration.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import unittest +from uuid import UUID + +from orgmetra_candidate_offer_response import build_candidate_offer_response +from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import HireAcceptanceCommand, HireAcceptanceResult +from orgmetra_people_api.offer_close import CandidateOfferHireVerification, close_accepted_offer_to_hire + +TENANT = UUID("0198a412-7000-7000-8000-000000000001") +CANDIDATE = UUID("0198a412-7000-7000-8000-000000000010") +DECISION = UUID("0198a412-7000-7000-8000-000000000011") +DIGEST_A = "a" * 64 +DIGEST_B = "b" * 64 +DIGEST_C = "c" * 64 +DIGEST_D = "d" * 64 + + +class RecordingAuthority: + """Record whether protected candidate/offer resolution was attempted.""" + + def __init__(self) -> None: + """Start with no protected-resolution calls.""" + self.calls = 0 + + def verify_offer_acceptance(self, **scope: object) -> CandidateOfferHireVerification: + """Return valid evidence only if incorrectly reached before denial.""" + self.calls += 1 + return CandidateOfferHireVerification( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=DECISION, + offer_response_digest=str(scope["offer_response_digest"]), + offer_approval_digest=DIGEST_A, + offer_terms_digest=DIGEST_B, + candidate_actor_reference="candidate:subject-17", + authority_evidence_reference="offer_hire_verification:6ba7b815-9dad-41d1-80b4-00c04fd430c8", + authority_evidence_digest=DIGEST_D, + ) + + +class RecordingPort: + """Fail if hire persistence is reached after an authorization denial.""" + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Raise because denied requests must never reach persistence.""" + del command, authorization + raise AssertionError("denied offer-to-hire request reached persistence") + + +class OfferToHireAuthorizationOrderTests(unittest.TestCase): + """Require purpose-bound authorization before protected offer/candidate resolution.""" + + def test_policy_denial_prevents_authority_resolution(self) -> None: + """Wrong-purpose callers must not invoke the candidate/offer authority resolver.""" + authority = RecordingAuthority() + response = build_candidate_offer_response( + tenant_record_id=str(TENANT), + offer_response_reference="candidate_offer_response:6ba7b811-9dad-41d1-80b4-00c04fd430c8", + candidate_profile_reference="candidate_profile:6ba7b810-9dad-41d1-80b4-00c04fd430c8", + offer_approval_reference="offer_approval:6ba7b812-9dad-41d1-80b4-00c04fd430c8", + offer_approval_digest=DIGEST_A, + offer_terms_reference="offer_terms:6ba7b813-9dad-41d1-80b4-00c04fd430c8", + offer_terms_digest=DIGEST_B, + candidate_actor_reference="candidate:subject-17", + identity_resolution_reference="identity_resolution:6ba7b814-9dad-41d1-80b4-00c04fd430c8", + identity_resolution_digest=DIGEST_C, + response_code="offer_accepted", + responded_at=datetime(2026, 8, 24, 7, 0, tzinfo=timezone.utc), + recorded_at=datetime(2026, 8, 24, 7, 1, tzinfo=timezone.utc), + ) + command = HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=DECISION, + person_record_id=UUID("0198a412-7000-7000-8000-000000000020"), + person_name_record_id=UUID("0198a412-7000-7000-8000-000000000021"), + employment_record_id=UUID("0198a412-7000-7000-8000-000000000030"), + employment_record_version_id=UUID("0198a412-7000-7000-8000-000000000031"), + candidate_worker_conversion_record_id=UUID("0198a412-7000-7000-8000-000000000040"), + audit_event_record_id=UUID("0198a412-7000-7000-8000-000000000050"), + outbox_delivery_record_id=UUID("0198a412-7000-7000-8000-000000000051"), + effective_from=date(2026, 8, 25), + display_name="Ada Lovelace", + idempotency_key="offer-close-auth-order-17", + ) + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), + ) + denied_policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people-hire-v1", + resource_kind="selection_decision", + purpose_code="benefits_admin", + operation_code="materialize_worker", + required_scope_code="orgmetra.people.materialize_worker", + permitted_fields=frozenset({"candidate_worker_conversion"}), + ) + + with self.assertRaises(AuthorizationDeniedError): + close_accepted_offer_to_hire( + response=response, + principal=principal, + command=command, + purpose_code="candidate_hire", + policy=denied_policy, + authority=authority, + mutation_port=RecordingPort(), + ) + + self.assertEqual(authority.calls, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_offer_to_hire_close.py b/services/people-api/tests/test_offer_to_hire_close.py new file mode 100644 index 000000000..b48d1c300 --- /dev/null +++ b/services/people-api/tests/test_offer_to_hire_close.py @@ -0,0 +1,435 @@ +"""Executable contracts for closing accepted offers through authoritative hire materialization.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import unittest +from uuid import UUID + +from orgmetra_candidate_offer_response import build_candidate_offer_response +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import HireAcceptanceCommand, HireAcceptanceResult +from orgmetra_people_api.offer_close import ( + CandidateOfferHireAuthority, + CandidateOfferHireVerification, + OfferToHireIntegrityError, + close_accepted_offer_to_hire, +) + +TENANT = UUID("0198a412-7000-7000-8000-000000000001") +OTHER_TENANT = UUID("0198a412-7000-7000-8000-000000000099") +CANDIDATE = UUID("0198a412-7000-7000-8000-000000000010") +OTHER_CANDIDATE = UUID("0198a412-7000-7000-8000-000000000098") +DECISION = UUID("0198a412-7000-7000-8000-000000000011") +PERSON = UUID("0198a412-7000-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-7000-7000-8000-000000000021") +EMPLOYMENT = UUID("0198a412-7000-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-7000-7000-8000-000000000031") +CONVERSION = UUID("0198a412-7000-7000-8000-000000000040") +AUDIT_EVENT = UUID("0198a412-7000-7000-8000-000000000050") +OUTBOX_DELIVERY = UUID("0198a412-7000-7000-8000-000000000051") +CANDIDATE_PROFILE_REFERENCE = "candidate_profile:6ba7b810-9dad-41d1-80b4-00c04fd430c8" +RESPONSE_REFERENCE = "candidate_offer_response:6ba7b811-9dad-41d1-80b4-00c04fd430c8" +OFFER_APPROVAL_REFERENCE = "offer_approval:6ba7b812-9dad-41d1-80b4-00c04fd430c8" +OFFER_TERMS_REFERENCE = "offer_terms:6ba7b813-9dad-41d1-80b4-00c04fd430c8" +IDENTITY_RESOLUTION_REFERENCE = "identity_resolution:6ba7b814-9dad-41d1-80b4-00c04fd430c8" +AUTHORITY_EVIDENCE_REFERENCE = "offer_hire_verification:6ba7b815-9dad-41d1-80b4-00c04fd430c8" +DIGEST_A = "a" * 64 +DIGEST_B = "b" * 64 +DIGEST_C = "c" * 64 +DIGEST_D = "d" * 64 +RESPONDED_AT = datetime(2026, 8, 24, 7, 0, tzinfo=timezone.utc) +RECORDED_AT = datetime(2026, 8, 24, 7, 1, tzinfo=timezone.utc) + + +def response(*, response_code: str = "offer_accepted", tenant_record_id: str | None = None): + """Build one value-minimized candidate offer response.""" + return build_candidate_offer_response( + tenant_record_id=tenant_record_id or str(TENANT), + offer_response_reference=RESPONSE_REFERENCE, + candidate_profile_reference=CANDIDATE_PROFILE_REFERENCE, + offer_approval_reference=OFFER_APPROVAL_REFERENCE, + offer_approval_digest=DIGEST_A, + offer_terms_reference=OFFER_TERMS_REFERENCE, + offer_terms_digest=DIGEST_B, + candidate_actor_reference="candidate:subject-17", + identity_resolution_reference=IDENTITY_RESOLUTION_REFERENCE, + identity_resolution_digest=DIGEST_C, + response_code=response_code, + responded_at=RESPONDED_AT, + recorded_at=RECORDED_AT, + ) + + +def command(**overrides: object) -> HireAcceptanceCommand: + """Build one deterministic confirmed-hire command.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "candidate_profile_id": CANDIDATE, + "selection_decision_id": DECISION, + "person_record_id": PERSON, + "person_name_record_id": PERSON_NAME, + "employment_record_id": EMPLOYMENT, + "employment_record_version_id": EMPLOYMENT_VERSION, + "candidate_worker_conversion_record_id": CONVERSION, + "audit_event_record_id": AUDIT_EVENT, + "outbox_delivery_record_id": OUTBOX_DELIVERY, + "effective_from": date(2026, 8, 25), + "display_name": "Ada Lovelace", + "idempotency_key": "offer-close-idempotency-17", + "employment_status_code": "active", + } + values.update(overrides) + return HireAcceptanceCommand(**values) # type: ignore[arg-type] + + +PRINCIPAL = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), +) +POLICY = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people-hire-v1", + resource_kind="selection_decision", + purpose_code="candidate_hire", + operation_code="materialize_worker", + required_scope_code="orgmetra.people.materialize_worker", + permitted_fields=frozenset({"candidate_worker_conversion"}), +) + + +class RecordingHirePort: + """Capture authoritative hire calls without persisting HR data.""" + + def __init__(self) -> None: + """Start with no persistence calls.""" + self.calls: list[HireAcceptanceCommand] = [] + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Record one already-authorized hire command.""" + del authorization + self.calls.append(command) + return HireAcceptanceResult( + person_record_id=command.person_record_id, + employment_record_id=command.employment_record_id, + candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, + ) + + +class RecordingAuthority: + """Resolve candidate-response evidence to exact authoritative hire scope.""" + + def __init__(self) -> None: + """Start with no authority calls and no forged output overrides.""" + self.calls: list[dict[str, object]] = [] + self.mutate_response = None + self.override: dict[str, object] = {} + + def verify_offer_acceptance(self, **scope: object) -> CandidateOfferHireVerification: + """Return exact-scope authority evidence for the reviewed response.""" + self.calls.append(scope) + if self.mutate_response is not None: + self.mutate_response() + values: dict[str, object] = { + "tenant_record_id": TENANT, + "candidate_profile_id": CANDIDATE, + "selection_decision_id": DECISION, + "offer_response_digest": scope["offer_response_digest"], + "offer_approval_digest": DIGEST_A, + "offer_terms_digest": DIGEST_B, + "candidate_actor_reference": "candidate:subject-17", + "authority_evidence_reference": AUTHORITY_EVIDENCE_REFERENCE, + "authority_evidence_digest": DIGEST_D, + } + values.update(self.override) + return CandidateOfferHireVerification(**values) # type: ignore[arg-type] + + +def valid_verification(**overrides: object) -> CandidateOfferHireVerification: + """Build one exact authoritative verification for constructor-integrity tests.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "candidate_profile_id": CANDIDATE, + "selection_decision_id": DECISION, + "offer_response_digest": DIGEST_C, + "offer_approval_digest": DIGEST_A, + "offer_terms_digest": DIGEST_B, + "candidate_actor_reference": "candidate:subject-17", + "authority_evidence_reference": AUTHORITY_EVIDENCE_REFERENCE, + "authority_evidence_digest": DIGEST_D, + } + values.update(overrides) + return CandidateOfferHireVerification(**values) # type: ignore[arg-type] + + +class OfferToHireCloseTests(unittest.TestCase): + """Prove candidate response is necessary evidence but never hire authority itself.""" + + def test_declined_offer_never_reaches_authority_or_hire_port(self) -> None: + """A candidate decline must stop before authoritative hire work.""" + authority = RecordingAuthority() + port = RecordingHirePort() + + with self.assertRaisesRegex(OfferToHireIntegrityError, "accepted"): + close_accepted_offer_to_hire( + response=response(response_code="offer_declined"), + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=port, + ) + + self.assertEqual(authority.calls, []) + self.assertEqual(port.calls, []) + + def test_accepted_offer_requires_authoritative_mapping_before_hire(self) -> None: + """A valid response reaches the existing confirmed-hire path only after exact-scope verification.""" + authority = RecordingAuthority() + port = RecordingHirePort() + packet = response() + + result = close_accepted_offer_to_hire( + response=packet, + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=port, + ) + + self.assertIsInstance(authority, CandidateOfferHireAuthority) + self.assertEqual(result.employment_record_id, EMPLOYMENT) + self.assertEqual(port.calls, [command()]) + self.assertEqual(len(authority.calls), 1) + scope = authority.calls[0] + self.assertEqual(scope["tenant_record_id"], str(TENANT)) + self.assertEqual(scope["candidate_profile_reference"], CANDIDATE_PROFILE_REFERENCE) + self.assertEqual(scope["selection_decision_id"], DECISION) + self.assertEqual(scope["offer_response_digest"], packet.sha256_digest()) + self.assertEqual(scope["offer_approval_digest"], DIGEST_A) + self.assertEqual(scope["offer_terms_digest"], DIGEST_B) + self.assertEqual(repr(valid_verification()), "CandidateOfferHireVerification()") + + def test_authority_scope_mismatch_blocks_hire(self) -> None: + """A response-to-selection mapping cannot be widened after authority verification.""" + for field_name, value, message in ( + ("tenant_record_id", OTHER_TENANT, "tenant"), + ("candidate_profile_id", OTHER_CANDIDATE, "candidate profile"), + ("selection_decision_id", UUID("0198a412-7000-7000-8000-000000000097"), "selection decision"), + ): + authority = RecordingAuthority() + authority.override[field_name] = value + port = RecordingHirePort() + with self.subTest(field_name=field_name), self.assertRaisesRegex(OfferToHireIntegrityError, message): + close_accepted_offer_to_hire( + response=response(), + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=port, + ) + self.assertEqual(port.calls, []) + + def test_authority_must_bind_exact_response_and_offer_evidence(self) -> None: + """Authoritative mapping must echo exact immutable response, offer, and candidate provenance.""" + for field_name, value in ( + ("offer_response_digest", "e" * 64), + ("offer_approval_digest", "e" * 64), + ("offer_terms_digest", "e" * 64), + ("candidate_actor_reference", "candidate:other-subject"), + ): + authority = RecordingAuthority() + authority.override[field_name] = value + port = RecordingHirePort() + with self.subTest(field_name=field_name), self.assertRaises(OfferToHireIntegrityError): + close_accepted_offer_to_hire( + response=response(), + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=port, + ) + self.assertEqual(port.calls, []) + + def test_authority_time_response_mutation_blocks_hire(self) -> None: + """Mutating response evidence during authority work must not authorize a hire.""" + packet = response() + authority = RecordingAuthority() + authority.mutate_response = lambda: object.__setattr__(packet, "offer_terms_digest", "f" * 64) + port = RecordingHirePort() + + with self.assertRaisesRegex(OfferToHireIntegrityError, "changed during authoritative verification"): + close_accepted_offer_to_hire( + response=packet, + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=port, + ) + + self.assertEqual(port.calls, []) + + def test_preexisting_response_tamper_fails_before_authority(self) -> None: + """A response rewritten before orchestration must fail before authority work.""" + packet = response() + object.__setattr__(packet, "offer_terms_digest", "f" * 64) + authority = RecordingAuthority() + + with self.assertRaisesRegex(OfferToHireIntegrityError, "not intact"): + close_accepted_offer_to_hire( + response=packet, + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=RecordingHirePort(), + ) + + self.assertEqual(authority.calls, []) + + def test_command_and_response_tenant_must_match_before_authority(self) -> None: + """A foreign-tenant command cannot reuse candidate response evidence.""" + authority = RecordingAuthority() + + with self.assertRaisesRegex(OfferToHireIntegrityError, "tenant"): + close_accepted_offer_to_hire( + response=response(), + principal=PRINCIPAL, + command=command(tenant_record_id=OTHER_TENANT), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=RecordingHirePort(), + ) + + self.assertEqual(authority.calls, []) + + def test_untrusted_runtime_types_fail_before_authoritative_work(self) -> None: + """Exact command/response types and one authority protocol are mandatory.""" + authority = RecordingAuthority() + with self.assertRaisesRegex(TypeError, "response must be the exact"): + close_accepted_offer_to_hire( + response=object(), # type: ignore[arg-type] + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=RecordingHirePort(), + ) + with self.assertRaisesRegex(TypeError, "command must be the exact"): + close_accepted_offer_to_hire( + response=response(), + principal=PRINCIPAL, + command=object(), # type: ignore[arg-type] + purpose_code="candidate_hire", + policy=POLICY, + authority=authority, + mutation_port=RecordingHirePort(), + ) + with self.assertRaisesRegex(TypeError, "authority must implement"): + close_accepted_offer_to_hire( + response=response(), + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=object(), # type: ignore[arg-type] + mutation_port=RecordingHirePort(), + ) + self.assertEqual(authority.calls, []) + + def test_verification_runtime_subclass_cannot_cross_trust_boundary(self) -> None: + """Caller-defined verification subtypes cannot become authoritative evidence.""" + class ForgedVerification(CandidateOfferHireVerification): + """Represent an untrusted subtype that must be rejected.""" + + class ForgedAuthority(RecordingAuthority): + """Return a forged verification subtype.""" + + def verify_offer_acceptance(self, **scope: object) -> CandidateOfferHireVerification: + """Construct the otherwise-valid forged subtype.""" + value = super().verify_offer_acceptance(**scope) + return ForgedVerification( + tenant_record_id=value.tenant_record_id, + candidate_profile_id=value.candidate_profile_id, + selection_decision_id=value.selection_decision_id, + offer_response_digest=value.offer_response_digest, + offer_approval_digest=value.offer_approval_digest, + offer_terms_digest=value.offer_terms_digest, + candidate_actor_reference=value.candidate_actor_reference, + authority_evidence_reference=value.authority_evidence_reference, + authority_evidence_digest=value.authority_evidence_digest, + ) + + with self.assertRaisesRegex(TypeError, "exact CandidateOfferHireVerification"): + close_accepted_offer_to_hire( + response=response(), + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=ForgedAuthority(), + mutation_port=RecordingHirePort(), + ) + + def test_verification_constructor_rejects_malformed_authority_evidence(self) -> None: + """Every trust-bearing authority field must fail closed before orchestration.""" + invalid_values = ( + {"tenant_record_id": UUID(int=0)}, + {"candidate_profile_id": "not-a-uuid"}, + {"selection_decision_id": UUID(int=(1 << 128) - 1)}, + {"offer_response_digest": "A" * 64}, + {"offer_approval_digest": 17}, + {"offer_terms_digest": "short"}, + {"candidate_actor_reference": "actor:wrong-owner"}, + {"candidate_actor_reference": "candidate:" + "x" * 300}, + {"authority_evidence_reference": 17}, + {"authority_evidence_reference": "offer_hire_verification:not-a-uuid"}, + {"authority_evidence_reference": "offer_hire_verification:6ba7b810-9dad-11d1-80b4-00c04fd430c8"}, + {"authority_evidence_digest": "z" * 64}, + ) + for overrides in invalid_values: + with self.subTest(overrides=overrides), self.assertRaises(ValueError): + valid_verification(**overrides) + + def test_post_construction_verification_rewrite_is_revalidated(self) -> None: + """Frozen verification evidence rewritten with object primitives must still fail closed.""" + authority = RecordingAuthority() + original = authority.verify_offer_acceptance(offer_response_digest=DIGEST_C) + object.__setattr__(original, "authority_evidence_digest", "z" * 64) + + class RewrittenAuthority: + """Return the rewritten verification object.""" + + def verify_offer_acceptance(self, **scope: object) -> CandidateOfferHireVerification: + """Ignore the current request and return the corrupted authority evidence.""" + del scope + return original + + with self.assertRaises(ValueError): + close_accepted_offer_to_hire( + response=response(), + principal=PRINCIPAL, + command=command(), + purpose_code="candidate_hire", + policy=POLICY, + authority=RewrittenAuthority(), + mutation_port=RecordingHirePort(), + ) + + +if __name__ == "__main__": + unittest.main()