diff --git a/.github/workflows/candidate-evidence-quality.yml b/.github/workflows/candidate-evidence-quality.yml new file mode 100644 index 000000000..45d02a7d1 --- /dev/null +++ b/.github/workflows/candidate-evidence-quality.yml @@ -0,0 +1,57 @@ +name: Candidate Evidence Quality + +on: + pull_request: + branches: + - develop + paths: + - "packages/candidate-evidence/**" + - ".github/requirements/foundation-test.txt" + - ".github/workflows/candidate-evidence-quality.yml" + - "docs/adr/0025-governed-candidate-evidence-intake.md" + - "docs/doctoring/candidate-evidence-intake-references.md" + - "docs/traceability/candidate-evidence-intake.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: candidate-evidence-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Candidate evidence contract and 100% coverage + 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 Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + 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 candidate evidence package + run: python -m compileall -q packages/candidate-evidence/src packages/candidate-evidence/tests + - name: Test candidate evidence with exact statement and branch coverage + env: + PYTHONPATH: packages/candidate-evidence/src + COVERAGE_FILE: /tmp/orgmetra-candidate-evidence.coverage + run: python -m pytest -c packages/candidate-evidence/pyproject.toml packages/candidate-evidence/tests + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/docs/adr/0025-governed-candidate-evidence-intake.md b/docs/adr/0025-governed-candidate-evidence-intake.md new file mode 100644 index 000000000..ab66b3088 --- /dev/null +++ b/docs/adr/0025-governed-candidate-evidence-intake.md @@ -0,0 +1,47 @@ +# ADR 0025: Govern candidate evidence intake as reference-only evidence + +- **Status:** Proposed — active PR only +- **Date:** 2026-08-19 + +## Context + +Orgmetra already owns candidate profiles, sealed selection-decision evidence, candidate-to-worker conversion, governed requisition review, and candidate-neutral interview planning. A buyer still needs a defensible intake boundary between receiving candidate-related material and later sealing or using it in a high-impact selection decision. + +Copying resumes, assessment values, demographic attributes, or other candidate content into every workflow envelope increases privacy exposure and makes retention, provenance, and purpose control harder to audit. A nominally opaque packet-owned reference is also unsafe if arbitrary human-readable/value-bearing suffixes are accepted: candidate names or other sensitive values can then be smuggled through reference fields. UUIDv1 adds a subtler correlation channel for packet-owned references because timestamp/node-derived metadata can be embedded in an otherwise canonical UUID. The authoritative tenant identifier is different: it is issued by Orgmetra core, so this leaf package must accept the canonical non-sentinel operational UUID contract owned by that boundary rather than silently imposing a second version policy. Ordinary object representations can create the same leak if they print sensitive correlation metadata in logs or assertion failures. Canonical UUID syntax also does not prove tenant ownership, so a valid reference from another tenant must not be accepted as authoritative evidence merely because its shape is correct. + +## Decision + +Add a transport-neutral `CandidateEvidenceIntakePacket` that binds: + +- canonical non-sentinel Orgmetra tenant identity and one UUIDv4-backed opaque intake reference; +- UUIDv4-backed candidate-profile, requisition, authoritative Job, job-requirements, evidence-set, provenance, handling-policy, retention-policy and actor references; +- independent SHA-256 digests where content drift matters; +- one accountable actor, bounded evidence-item count, fixed `candidate_evidence_intake` purpose, bounded reason metadata, and precision-preserving UTC collection time; +- mandatory human confirmation, immutable `requires_human_review` state, actionable next-step copy, and a fully redacted ordinary object representation. + +`tenant_record_id` must be canonical and non-sentinel under Orgmetra's authoritative operational UUID contract. The package does not reinterpret its UUID version because tenant identity generation and migration policy belong to the authoritative HRIS boundary. Packet-owned namespaced trust references separately require canonical non-sentinel UUIDv4 and the expected prefix. Human-readable/value-bearing suffixes, UUIDv1, and other non-v4 suffixes are rejected fail-closed for those references. Before sealing or accountable review, the host must re-resolve **every packet reference** within the exact `tenant_record_id` through its authoritative boundary, then prove candidate↔requisition↔Job correlation and verify provenance, handling, retention, and completeness. UUID syntax is only an early shape/opacity guard; it is not tenant authority or relationship evidence. The governance packet contains no candidate name, email, demographic attribute, assessment value, raw document content, credential, or free-form model output. UUID-backed tenant and candidate correlation remain sensitive metadata rather than anonymous data. + +Canonical JSON plus SHA-256 provide immutable audit correlation but do not establish evidence truth, scientific validity, fairness, legal compliance, authorization, policy enforcement, or final approval. + +## Consequences + +### Positive + +- Recruiting workflows can correlate candidate evidence to the exact Job/requisition and policy versions without duplicating candidate content. +- Packet-owned reference fields cannot quietly become a human-readable PII/value channel or UUIDv1 timestamp/node correlation channel, while the leaf package remains interoperable with authoritative Orgmetra tenant UUIDs. +- Cross-tenant evidence mixing is fail-closed at the host sealing/review boundary because every packet reference must resolve within the packet tenant. +- Source provenance, purpose-bound handling, and retention are explicit versioned evidence rather than prose-only assumptions. +- Ordinary logging/assertion formatting does not emit candidate correlation or evidence digests. +- The package remains standalone and MSA-friendly and performs no cross-service application-table SQL. + +### Costs and constraints + +- Packet-owned trust references must use durable UUIDv4-backed public identifiers and must be authoritatively resolved in the exact tenant before use. Tenant UUID generation/privacy policy remains owned by the authoritative HRIS boundary. +- The packet does not store raw candidate evidence, decide whether an item is lawfully usable, or prove the referenced policy was followed. +- UUID-backed tenant and candidate references are still sensitive correlating metadata and require least-privilege handling. +- Evidence sealing, authoritative selection decisions, immutable audit/outbox, deletion/retention execution, export controls, accommodations, adverse-impact monitoring, and jurisdiction-specific legal review remain separate obligations. +- This ADR remains proposed until its exact PR head merges into protected `develop`. + +## References + +See `docs/doctoring/candidate-evidence-intake-references.md`. diff --git a/docs/doctoring/candidate-evidence-intake-references.md b/docs/doctoring/candidate-evidence-intake-references.md new file mode 100644 index 000000000..47edcb351 --- /dev/null +++ b/docs/doctoring/candidate-evidence-intake-references.md @@ -0,0 +1,17 @@ +# Candidate evidence intake references + +These sources inform the active-PR intake boundary. They do not establish certification, jurisdiction-specific legal compliance, or permission to collect/use any particular candidate evidence item. + +## APA 7 references + +International Organization for Standardization. (2023). *ISO 30405:2023 human resource management — Guidelines on recruitment* (2nd ed.). https://www.iso.org/standard/79488.html + +International Organization for Standardization, & International Electrotechnical Commission. (2025). *ISO/IEC 27701:2025 information security, cybersecurity and privacy protection — Privacy information management systems — Requirements and guidance* (2nd ed.). https://www.iso.org/standard/27701 + +U.S. Equal Employment Opportunity Commission. (n.d.). *Regulations and guidelines*. Retrieved August 19, 2026, from https://www.eeoc.gov/regulations-and-guidelines + +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 + +## Applied boundary + +ISO 30405:2023 provides current recruitment-process guidance spanning preparation, sourcing, assessment, employment and review. ISO/IEC 27701:2025 provides current requirements and guidance for privacy-information management. EEOC identifies 29 CFR Part 1607 as the Uniform Guidelines on Employee Selection Procedures and notes that selection procedures should be evaluated for job relatedness and disparate impact. Orgmetra therefore keeps candidate values outside this governance packet while binding the exact Job requirements, evidence/provenance identities, handling and retention policy versions, accountable actor, purpose and human-review state needed for downstream audit. diff --git a/docs/traceability/candidate-evidence-intake.md b/docs/traceability/candidate-evidence-intake.md new file mode 100644 index 000000000..62955813c --- /dev/null +++ b/docs/traceability/candidate-evidence-intake.md @@ -0,0 +1,23 @@ +# Candidate evidence intake traceability + +- **Maturity:** `implemented_on_active_pr` +- **Buyer capability:** Candidate Evidence workspace governance boundary +- **Owned contract:** `CandidateEvidenceIntakePacket` + +| Requirement | Evidence | +|---|---| +| Correlate candidate evidence to the correct recruiting context | Canonical non-sentinel tenant identity under the Orgmetra core operational-UUID contract plus UUIDv4-backed candidate-profile, requisition and Job references; immutable next action requires every packet reference to be re-resolved within the exact tenant and candidate↔requisition↔Job correlation verified before sealing/review | +| Prevent cross-tenant evidence mixing | `test_tenant_scope.py` requires tenant-scoped resolution of every packet reference before correlation/provenance checks and authoritative sealing; UUID syntax alone is not tenant authority | +| Prevent packet-owned reference fields from becoming a covert PII/value/correlation channel without duplicating tenant identity policy | `tenant_record_id` is canonical/non-sentinel under the authoritative core contract; every packet-owned governed reference requires canonical non-sentinel UUIDv4 plus its expected prefix. Human-readable/value-bearing suffixes and UUIDv1/non-v4 reference suffixes are rejected; `test_reference_privacy.py` covers authoritative UUIDv7 tenant interoperability plus every trust-reference field through builder and replacement paths | +| Preserve job-related evidence context | Versioned job-requirements reference + SHA-256 digest | +| Preserve evidence identity and source provenance without copying values | Evidence-set and source-provenance references + independent SHA-256 digests | +| Bind privacy/operational handling | Handling-policy and retention-policy references + independent SHA-256 digests | +| Version actor/purpose/reason evidence explicitly | `evidence_version` is a true positive integer through signed-int32 max and participates in canonical JSON/SHA-256; `test_evidence_version.py` proves presence, digest separation, bounds, and `dataclasses.replace(...)` revalidation | +| Preserve accountable collection context | Actor reference, fixed purpose, bounded reason, exact evidence-item count and precision-preserving UTC time | +| Prevent packet-as-decision misuse | Exact boolean human confirmation, immutable `requires_human_review` state and fixed next action | +| Prevent ordinary logs/assertions from leaking candidate correlation | generated dataclass repr disabled; `repr(packet)` is `CandidateEvidenceIntakePacket()`; repr-redaction regression | +| Preserve deterministic audit correlation | Canonical JSON + independently testable SHA-256 digest | +| Minimize candidate content exposure | No candidate name/email/demographic attribute/assessment value/raw evidence/credential/free-form model output in the packet | +| Keep service ownership boundaries intact | No database migration, no provider execution, no cross-service application-table SQL | + +The packet is correlation evidence, not proof of evidence truth, tenant ownership until authoritative resolution, lawful use, selection validity, fairness, policy execution, evidence sealing, or a final employment decision. UUIDv4 is an opacity/privacy constraint for packet-owned trust references only; tenant UUID generation/privacy policy remains owned by the authoritative HRIS boundary. Those claims require their own owner-bound evidence. diff --git a/packages/candidate-evidence/CHANGELOG.md b/packages/candidate-evidence/CHANGELOG.md new file mode 100644 index 000000000..162d4cb86 --- /dev/null +++ b/packages/candidate-evidence/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + +- Add a PII-minimized candidate-evidence intake packet with exact candidate/requisition/Job/job-requirements correlation. +- Follow Orgmetra's authoritative canonical non-sentinel operational UUID contract for `tenant_record_id`, while packet-owned evidence, source-provenance, handling and retention references remain canonical UUIDv4-backed opaque identity; content-bearing evidence also carries SHA-256 digests. +- Reject human-readable/value-bearing reference suffixes, sentinel/noncanonical reference UUIDs, and UUIDv1/non-v4 packet-reference suffixes so trust-reference fields cannot become a covert candidate-data or correlation channel without duplicating tenant identity policy. +- Require every packet reference to be re-resolved within the exact tenant through its authoritative boundary before candidate↔requisition↔Job correlation, evidence sealing, or accountable review, preventing cross-tenant evidence mixing behind valid UUID syntax. +- Redact the ordinary packet representation so candidate correlation and evidence digests are not emitted through routine logs/assertion failures. +- Require bounded evidence counts, fixed purpose, explicit human-review state, precision-preserving UTC timestamps, deterministic canonical JSON, and exact packet SHA-256. +- Keep raw candidate evidence, demographic attributes, assessment values, credentials, and free-form model output outside the governance packet. diff --git a/packages/candidate-evidence/README.md b/packages/candidate-evidence/README.md new file mode 100644 index 000000000..4d29f2674 --- /dev/null +++ b/packages/candidate-evidence/README.md @@ -0,0 +1,11 @@ +# Orgmetra candidate evidence intake + +This package defines a transport-neutral, reference-only packet for admitting candidate evidence into an accountable recruiting workflow without copying the candidate's evidence values into the governance envelope. + +`CandidateEvidenceIntakePacket` binds one tenant, candidate profile, requisition, authoritative Job, versioned job requirements, evidence set, source-provenance manifest, handling policy, retention policy, accountable actor, purpose/reason, bounded positive `evidence_version`, evidence-item count, and collection time. `tenant_record_id` follows Orgmetra's authoritative canonical non-sentinel operational UUID contract rather than imposing a second UUID-version policy at this leaf package. Packet-owned trust-bearing references remain canonical non-sentinel UUIDv4 values and additionally require their expected namespaces. UUIDv1 and other non-v4 suffixes are rejected for those references so timestamp/node-derived or otherwise nonconforming identifiers cannot masquerade as the package's opaque trust-reference format. Content-bearing evidence also carries an independent lowercase SHA-256 digest. `evidence_version` is part of canonical JSON and SHA-256 evidence so actor/purpose/reason evidence from different contract revisions cannot silently collide; it must be a true integer from 1 through 2147483647. + +The packet deliberately contains no candidate name, email, demographic attribute, assessment value, raw resume/document content, credential, or free-form model output. Human-readable/value-bearing reference suffixes are rejected so those fields cannot become a covert PII channel. UUID-backed tenant and candidate correlation remain sensitive metadata and must still be handled under the bound purpose, handling policy, retention policy, least-privilege authorization, export controls, and audit boundary. The ordinary object representation is fully redacted to avoid leaking correlation/evidence through logs or assertion failures. + +The packet cannot represent approval. `human_confirmation_required` must be the boolean singleton `True`, `review_state` is fixed to `requires_human_review`, and its immutable next action requires the host to re-resolve **every packet reference** within the exact `tenant_record_id` through its authoritative boundary before evidence sealing. The host must then verify candidate↔requisition↔Job correlation, job relevance, source provenance, permitted handling, retention, and evidence completeness before requesting authoritative evidence sealing and accountable human review. Reference UUIDv4 syntax constrains packet-owned trust-reference opacity only; it does not prove tenant ownership, record relationship, or authorization, and tenant UUID generation/privacy policy remains owned by the authoritative HRIS boundary. + +Canonical JSON and SHA-256 support immutable correlation only. They prove neither the truth of referenced evidence nor selection validity, fairness, legal compliance, authorization to use a particular evidence item, or final human approval. Authoritative persistence, tenant-scoped reference resolution, evidence sealing, selection decisions, audit/outbox, and candidate-to-worker conversion remain separate Orgmetra boundaries. diff --git a/packages/candidate-evidence/pyproject.toml b/packages/candidate-evidence/pyproject.toml new file mode 100644 index 000000000..7d9f42023 --- /dev/null +++ b/packages/candidate-evidence/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "orgmetra-candidate-evidence" +version = "0.1.0" +description = "PII-minimized candidate-evidence intake correlation 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_evidence", + "--cov-branch", + "--cov-report=term-missing", + "--cov-fail-under=100", +] diff --git a/packages/candidate-evidence/src/orgmetra_candidate_evidence/__init__.py b/packages/candidate-evidence/src/orgmetra_candidate_evidence/__init__.py new file mode 100644 index 000000000..643fe42b1 --- /dev/null +++ b/packages/candidate-evidence/src/orgmetra_candidate_evidence/__init__.py @@ -0,0 +1,5 @@ +"""Public candidate-evidence intake contract.""" + +from .packet import CandidateEvidenceIntakePacket, build_candidate_evidence_intake_packet + +__all__ = ["CandidateEvidenceIntakePacket", "build_candidate_evidence_intake_packet"] diff --git a/packages/candidate-evidence/src/orgmetra_candidate_evidence/packet.py b/packages/candidate-evidence/src/orgmetra_candidate_evidence/packet.py new file mode 100644 index 000000000..628488eb6 --- /dev/null +++ b/packages/candidate-evidence/src/orgmetra_candidate_evidence/packet.py @@ -0,0 +1,252 @@ +"""Governed, PII-minimized candidate-evidence intake correlation. + +The contract binds candidate evidence intake to an authoritative candidate, requisition, +Job, job requirements, evidence-set identity, handling/retention policy, and accountable +actor. It carries no raw candidate evidence values; UUID-backed opaque references remain +sensitive correlating metadata and are redacted from the ordinary object representation. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from hashlib import sha256 +import json +import re +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])?$" +) +_PURPOSE_CODE = "candidate_evidence_intake" +_REVIEW_STATE = "requires_human_review" +_ALLOWED_REASON_CODES = frozenset({"requisition_candidate_review"}) +_NEXT_ACTION = ( + "Re-resolve every packet reference within tenant_record_id through its authoritative " + "boundary; verify candidate, requisition, and Job correlation; then verify job relevance, " + "source provenance, permitted handling, retention, and evidence completeness before " + "requesting authoritative evidence sealing and accountable human review." +) + + +def _validate_operational_uuid(value: str, field_name: str) -> None: + """Require canonical non-sentinel UUID text owned by the authoritative HRIS.""" + 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: + """Require a bounded descriptive lower snake_case governance code.""" + 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_reference(value: str, prefix: str, field_name: str) -> None: + """Require the expected namespace plus a canonical opaque UUIDv4 suffix.""" + 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 _validate_digest(value: str, field_name: str) -> None: + """Require lowercase SHA-256 hexadecimal evidence.""" + if not isinstance(value, str) or not _DIGEST_PATTERN.fullmatch(value): + raise ValueError(f"{field_name} must be lowercase SHA-256 hex") + + +def _canonical_timestamp(value: datetime) -> str: + """Render an aware instant as precision-preserving UTC RFC 3339 text.""" + if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None: + raise ValueError("collected_at must be timezone-aware") + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +@dataclass(frozen=True, slots=True, repr=False) +class CandidateEvidenceIntakePacket: + """Immutable reference-only candidate-evidence intake awaiting human review.""" + + tenant_record_id: str + intake_reference: str + candidate_profile_reference: str + requisition_reference: str + job_profile_reference: str + job_requirements_reference: str + job_requirements_digest: str + evidence_set_reference: str + evidence_set_digest: str + source_provenance_reference: str + source_provenance_digest: str + handling_policy_reference: str + handling_policy_digest: str + retention_policy_reference: str + retention_policy_digest: str + actor_reference: str + evidence_item_count: int + purpose_code: str + reason_code: str + collected_at: datetime + human_confirmation_required: bool = True + review_state: str = _REVIEW_STATE + next_action: str = _NEXT_ACTION + evidence_version: int = 1 + + def __repr__(self) -> str: + """Return a representation that never emits candidate correlation evidence.""" + return "CandidateEvidenceIntakePacket()" + + def __post_init__(self) -> None: + """Fail closed when direct construction drifts from the governed contract.""" + _validate_operational_uuid(self.tenant_record_id, "tenant_record_id") + _validate_reference(self.intake_reference, "candidate_evidence_intake", "intake_reference") + _validate_reference( + self.candidate_profile_reference, + "candidate_profile", + "candidate_profile_reference", + ) + _validate_reference(self.requisition_reference, "requisition", "requisition_reference") + _validate_reference(self.job_profile_reference, "job_profile", "job_profile_reference") + _validate_reference( + self.job_requirements_reference, + "job_requirements", + "job_requirements_reference", + ) + _validate_digest(self.job_requirements_digest, "job_requirements_digest") + _validate_reference(self.evidence_set_reference, "evidence_set", "evidence_set_reference") + _validate_digest(self.evidence_set_digest, "evidence_set_digest") + _validate_reference( + self.source_provenance_reference, + "source_provenance", + "source_provenance_reference", + ) + _validate_digest(self.source_provenance_digest, "source_provenance_digest") + _validate_reference( + self.handling_policy_reference, + "handling_policy", + "handling_policy_reference", + ) + _validate_digest(self.handling_policy_digest, "handling_policy_digest") + _validate_reference( + self.retention_policy_reference, + "retention_policy", + "retention_policy_reference", + ) + _validate_digest(self.retention_policy_digest, "retention_policy_digest") + _validate_reference(self.actor_reference, "actor", "actor_reference") + if type(self.evidence_item_count) is not int or not 1 <= self.evidence_item_count <= 100: + raise ValueError("evidence_item_count must be an integer from 1 through 100") + _validate_code(self.purpose_code, "purpose_code") + if self.purpose_code != _PURPOSE_CODE: + raise ValueError("purpose_code must remain candidate_evidence_intake") + _validate_code(self.reason_code, "reason_code") + if self.reason_code not in _ALLOWED_REASON_CODES: + raise ValueError("reason_code must use a reviewed non-sensitive candidate-evidence reason") + _canonical_timestamp(self.collected_at) + 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") + if self.human_confirmation_required is not True: + raise ValueError("human confirmation is mandatory before sealing candidate evidence") + if self.review_state != _REVIEW_STATE: + raise ValueError("review_state must remain requires_human_review") + if self.next_action != _NEXT_ACTION: + raise ValueError("next_action must remain the governed candidate-evidence instruction") + + def canonical_json(self) -> str: + """Return deterministic canonical JSON for immutable audit correlation.""" + payload = { + "actor_reference": self.actor_reference, + "candidate_profile_reference": self.candidate_profile_reference, + "collected_at": _canonical_timestamp(self.collected_at), + "evidence_item_count": self.evidence_item_count, + "evidence_set_digest": self.evidence_set_digest, + "evidence_set_reference": self.evidence_set_reference, + "evidence_version": self.evidence_version, + "handling_policy_digest": self.handling_policy_digest, + "handling_policy_reference": self.handling_policy_reference, + "human_confirmation_required": self.human_confirmation_required, + "intake_reference": self.intake_reference, + "job_profile_reference": self.job_profile_reference, + "job_requirements_digest": self.job_requirements_digest, + "job_requirements_reference": self.job_requirements_reference, + "next_action": self.next_action, + "purpose_code": self.purpose_code, + "reason_code": self.reason_code, + "requisition_reference": self.requisition_reference, + "retention_policy_digest": self.retention_policy_digest, + "retention_policy_reference": self.retention_policy_reference, + "review_state": self.review_state, + "source_provenance_digest": self.source_provenance_digest, + "source_provenance_reference": self.source_provenance_reference, + "tenant_record_id": self.tenant_record_id, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + def sha256_digest(self) -> str: + """Return SHA-256 over the exact canonical UTF-8 intake packet.""" + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +def build_candidate_evidence_intake_packet( + *, + tenant_record_id: str, + intake_reference: str, + candidate_profile_reference: str, + requisition_reference: str, + job_profile_reference: str, + job_requirements_reference: str, + job_requirements_digest: str, + evidence_set_reference: str, + evidence_set_digest: str, + source_provenance_reference: str, + source_provenance_digest: str, + handling_policy_reference: str, + handling_policy_digest: str, + retention_policy_reference: str, + retention_policy_digest: str, + actor_reference: str, + evidence_item_count: int, + purpose_code: str, + reason_code: str, + collected_at: datetime, + evidence_version: int = 1, +) -> CandidateEvidenceIntakePacket: + """Build a reference-only candidate-evidence packet pending accountable review.""" + return CandidateEvidenceIntakePacket( + tenant_record_id=tenant_record_id, + intake_reference=intake_reference, + candidate_profile_reference=candidate_profile_reference, + requisition_reference=requisition_reference, + job_profile_reference=job_profile_reference, + job_requirements_reference=job_requirements_reference, + job_requirements_digest=job_requirements_digest, + evidence_set_reference=evidence_set_reference, + evidence_set_digest=evidence_set_digest, + source_provenance_reference=source_provenance_reference, + source_provenance_digest=source_provenance_digest, + handling_policy_reference=handling_policy_reference, + handling_policy_digest=handling_policy_digest, + retention_policy_reference=retention_policy_reference, + retention_policy_digest=retention_policy_digest, + actor_reference=actor_reference, + evidence_item_count=evidence_item_count, + purpose_code=purpose_code, + reason_code=reason_code, + collected_at=collected_at, + evidence_version=evidence_version, + ) diff --git a/packages/candidate-evidence/tests/test_docstrings.py b/packages/candidate-evidence/tests/test_docstrings.py new file mode 100644 index 000000000..64d78c0ec --- /dev/null +++ b/packages/candidate-evidence/tests/test_docstrings.py @@ -0,0 +1,31 @@ +"""Executable documentation-completeness contract for candidate evidence intake.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = PACKAGE_ROOT / "src" / "orgmetra_candidate_evidence" +TEST_ROOT = PACKAGE_ROOT / "tests" + + +def _python_files() -> tuple[Path, ...]: + """Return every owned Python source and test file in deterministic order.""" + return tuple(sorted((*SOURCE_ROOT.glob("*.py"), *TEST_ROOT.glob("*.py")))) + + +def test_owned_python_modules_and_callables_are_documented() -> None: + """Require beginner-readable docstrings on all owned modules, classes, and callables.""" + missing: list[str] = [] + for path in _python_files(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if ast.get_docstring(tree, clean=False) is None: + missing.append(f"{path.relative_to(PACKAGE_ROOT)}:") + for node in ast.walk(tree): + if not isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if ast.get_docstring(node, clean=False) is None: + missing.append(f"{path.relative_to(PACKAGE_ROOT)}:{node.lineno}:{node.name}") + assert not missing, "Missing owned Python docstrings: " + ", ".join(missing) diff --git a/packages/candidate-evidence/tests/test_evidence_version.py b/packages/candidate-evidence/tests/test_evidence_version.py new file mode 100644 index 000000000..a95e82ce5 --- /dev/null +++ b/packages/candidate-evidence/tests/test_evidence_version.py @@ -0,0 +1,39 @@ +"""Regression coverage for explicit high-impact candidate-evidence versioning.""" + +from dataclasses import replace +import json + +import pytest + +from orgmetra_candidate_evidence import CandidateEvidenceIntakePacket + +from test_packet import values + + +def _packet(evidence_version: int = 1) -> CandidateEvidenceIntakePacket: + """Build one valid packet while varying only its immutable evidence version.""" + data = values() + data["evidence_version"] = evidence_version + return CandidateEvidenceIntakePacket(**data) + + +def test_evidence_version_is_canonical_evidence() -> None: + """Bind the explicit evidence version into canonical JSON and its SHA-256 digest.""" + first = _packet(1) + second = _packet(2) + assert json.loads(first.canonical_json())["evidence_version"] == 1 + assert first.canonical_json() != second.canonical_json() + assert first.sha256_digest() != second.sha256_digest() + + +@pytest.mark.parametrize("evidence_version", [True, False, 0, -1, 2_147_483_648, "1", 1.0]) +def test_rejects_noncanonical_evidence_versions(evidence_version: object) -> None: + """Reject booleans, non-integers, non-positive values, and signed-int32 overflow.""" + with pytest.raises(ValueError, match="evidence_version"): + _packet(evidence_version) # type: ignore[arg-type] + + +def test_replace_cannot_bypass_evidence_version_validation() -> None: + """Revalidate explicit evidence-version bounds when immutable packets are copied.""" + with pytest.raises(ValueError, match="evidence_version"): + replace(_packet(), evidence_version=0) diff --git a/packages/candidate-evidence/tests/test_packet.py b/packages/candidate-evidence/tests/test_packet.py new file mode 100644 index 000000000..ed9b7151c --- /dev/null +++ b/packages/candidate-evidence/tests/test_packet.py @@ -0,0 +1,256 @@ +"""Regression coverage for governed candidate-evidence intake packets.""" + +from dataclasses import replace +from datetime import datetime, timedelta, timezone, tzinfo +from hashlib import sha256 +import json + +import pytest + +from orgmetra_candidate_evidence import ( + CandidateEvidenceIntakePacket, + build_candidate_evidence_intake_packet, +) + +TENANT = "12345678-1234-4234-8234-123456789abc" +REF = { + "intake": "11111111-1111-4111-8111-111111111111", + "candidate": "22222222-2222-4222-8222-222222222222", + "requisition": "33333333-3333-4333-8333-333333333333", + "job": "44444444-4444-4444-8444-444444444444", + "requirements": "55555555-5555-4555-8555-555555555555", + "evidence": "66666666-6666-4666-8666-666666666666", + "source": "77777777-7777-4777-8777-777777777777", + "handling": "88888888-8888-4888-8888-888888888888", + "retention": "99999999-9999-4999-8999-999999999999", + "actor": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", +} +DIGEST_A = "a" * 64 +DIGEST_B = "b" * 64 +DIGEST_C = "c" * 64 +DIGEST_D = "d" * 64 +DIGEST_E = "e" * 64 +EXPECTED_NEXT_ACTION = ( + "Re-resolve every packet reference within tenant_record_id through its authoritative " + "boundary; verify candidate, requisition, and Job correlation; then verify job relevance, " + "source provenance, permitted handling, retention, and evidence completeness before " + "requesting authoritative evidence sealing and accountable human review." +) + + +def values() -> dict[str, object]: + """Return one valid candidate-evidence packet input mapping for focused overrides.""" + return dict( + tenant_record_id=TENANT, + intake_reference=f"candidate_evidence_intake:{REF['intake']}", + candidate_profile_reference=f"candidate_profile:{REF['candidate']}", + requisition_reference=f"requisition:{REF['requisition']}", + job_profile_reference=f"job_profile:{REF['job']}", + job_requirements_reference=f"job_requirements:{REF['requirements']}", + job_requirements_digest=DIGEST_A, + evidence_set_reference=f"evidence_set:{REF['evidence']}", + evidence_set_digest=DIGEST_B, + source_provenance_reference=f"source_provenance:{REF['source']}", + source_provenance_digest=DIGEST_C, + handling_policy_reference=f"handling_policy:{REF['handling']}", + handling_policy_digest=DIGEST_D, + retention_policy_reference=f"retention_policy:{REF['retention']}", + retention_policy_digest=DIGEST_E, + actor_reference=f"actor:{REF['actor']}", + evidence_item_count=5, + purpose_code="candidate_evidence_intake", + reason_code="requisition_candidate_review", + collected_at=datetime(2026, 8, 19, 1, 2, 3, 456789, tzinfo=timezone.utc), + ) + + +def test_builds_reference_only_deterministic_packet() -> None: + """Build deterministic reference-only evidence without candidate value duplication.""" + packet = build_candidate_evidence_intake_packet(**values()) + payload = json.loads(packet.canonical_json()) + assert payload["review_state"] == "requires_human_review" + assert payload["human_confirmation_required"] is True + assert payload["next_action"] == EXPECTED_NEXT_ACTION + assert payload["collected_at"].endswith(".456789Z") + assert payload["candidate_profile_reference"] == f"candidate_profile:{REF['candidate']}" + forbidden = ( + "candidate_name", + "email", + "demographic", + "assessment_value", + "raw_evidence", + "model_output", + ) + assert all(name not in payload for name in forbidden) + assert packet.sha256_digest() == sha256(packet.canonical_json().encode("utf-8")).hexdigest() + assert packet == CandidateEvidenceIntakePacket(**values()) + + +def test_existing_positional_optional_arguments_retain_meaning() -> None: + """Keep existing positional callers bound to review controls after versioning.""" + packet = CandidateEvidenceIntakePacket( + *values().values(), + True, + "requires_human_review", + EXPECTED_NEXT_ACTION, + ) + assert packet.human_confirmation_required is True + assert packet.review_state == "requires_human_review" + assert packet.evidence_version == 1 + + +@pytest.mark.parametrize( + "field,bad", + [ + ("tenant_record_id", "not-a-uuid"), + ("tenant_record_id", "00000000-0000-0000-0000-000000000000"), + ("tenant_record_id", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"), + ("intake_reference", "wrong:intake"), + ("candidate_profile_reference", "wrong:candidate"), + ("requisition_reference", "wrong:req"), + ("job_profile_reference", "wrong:job"), + ("job_requirements_reference", "wrong:reqs"), + ("evidence_set_reference", "wrong:evidence"), + ("source_provenance_reference", "wrong:source"), + ("handling_policy_reference", "wrong:handling"), + ("retention_policy_reference", "wrong:retention"), + ("actor_reference", "wrong:actor"), + ("job_requirements_digest", "A" * 64), + ("evidence_set_digest", "b" * 63), + ("source_provenance_digest", "C" * 64), + ("handling_policy_digest", "d" * 63), + ("retention_policy_digest", 7), + ("purpose_code", "wrong_purpose"), + ("purpose_code", "bad"), + ("reason_code", "Bad Reason"), + ("reason_code", "a_" + "b" * 64), + ("collected_at", datetime(2026, 8, 19, 1, 2, 3)), + ("human_confirmation_required", False), + ("human_confirmation_required", 1), + ("review_state", "approved"), + ("next_action", "Skip human review"), + ], +) +def test_rejects_invalid_scalar_contract(field: str, bad: object) -> None: + """Reject malformed scalar governance metadata and attempts to bypass review state.""" + data = values() + data[field] = bad + with pytest.raises((ValueError, TypeError)): + CandidateEvidenceIntakePacket(**data) + + +@pytest.mark.parametrize( + ("field", "prefix"), + [ + ("intake_reference", "candidate_evidence_intake"), + ("candidate_profile_reference", "candidate_profile"), + ("requisition_reference", "requisition"), + ("job_profile_reference", "job_profile"), + ("job_requirements_reference", "job_requirements"), + ("evidence_set_reference", "evidence_set"), + ("source_provenance_reference", "source_provenance"), + ("handling_policy_reference", "handling_policy"), + ("retention_policy_reference", "retention_policy"), + ("actor_reference", "actor"), + ], +) +def test_reference_suffixes_are_opaque_canonical_operational_uuids( + field: str, prefix: str +) -> None: + """Reject semantic, sentinel, and noncanonical values in opaque reference suffixes.""" + packet = CandidateEvidenceIntakePacket(**values()) + for suffix in ( + "Jane-Doe", + "00000000-0000-0000-0000-000000000000", + "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + ): + with pytest.raises(ValueError): + replace(packet, **{field: f"{prefix}:{suffix}"}) + + +def test_repr_redacts_candidate_correlation_and_evidence() -> None: + """Keep candidate correlation, actor identity, and evidence digests out of repr output.""" + packet = CandidateEvidenceIntakePacket(**values()) + rendered = repr(packet) + assert rendered == "CandidateEvidenceIntakePacket()" + assert packet.tenant_record_id not in rendered + assert packet.candidate_profile_reference not in rendered + assert packet.actor_reference not in rendered + assert packet.evidence_set_digest not in rendered + + +@pytest.mark.parametrize("count", [True, 0, 101, 1.0]) +def test_rejects_invalid_evidence_item_count(count: object) -> None: + """Require a bounded true integer count rather than booleans or numeric lookalikes.""" + data = values() + data["evidence_item_count"] = count + with pytest.raises(ValueError, match="evidence_item_count"): + CandidateEvidenceIntakePacket(**data) + + +class UnknownOffset(tzinfo): + """Timezone fixture whose UTC offset cannot be resolved.""" + + def utcoffset(self, dt: datetime | None) -> None: + """Return an unknown offset to exercise fail-closed timestamp validation.""" + return None + + def dst(self, dt: datetime | None) -> None: + """Return no daylight-saving offset for the unknown-offset fixture.""" + return None + + +def test_rejects_timezone_with_unknown_offset() -> None: + """Reject datetime values whose timezone object cannot resolve an absolute instant.""" + data = values() + data["collected_at"] = datetime(2026, 8, 19, tzinfo=UnknownOffset()) + with pytest.raises(ValueError, match="timezone-aware"): + CandidateEvidenceIntakePacket(**data) + + +def test_canonicalizes_non_utc_offset_and_preserves_fractional_precision() -> None: + """Normalize an aware local instant to UTC without dropping microsecond evidence.""" + data = values() + data["collected_at"] = datetime( + 2026, 8, 19, 10, 2, 3, 456789, tzinfo=timezone(timedelta(hours=9)) + ) + payload = json.loads(CandidateEvidenceIntakePacket(**data).canonical_json()) + assert payload["collected_at"] == "2026-08-19T01:02:03.456789Z" + + +def test_distinct_subsecond_instants_produce_distinct_evidence() -> None: + """Preserve digest separation for valid evidence timestamps one microsecond apart.""" + first = CandidateEvidenceIntakePacket(**values()) + data = values() + collected_at = data["collected_at"] + assert isinstance(collected_at, datetime) + data["collected_at"] = collected_at.replace(microsecond=456790) + second = CandidateEvidenceIntakePacket(**data) + assert first.canonical_json() != second.canonical_json() + assert first.sha256_digest() != second.sha256_digest() + + +def test_direct_replace_is_revalidated() -> None: + """Re-run invariant validation when an immutable packet is copied with new fields.""" + packet = CandidateEvidenceIntakePacket(**values()) + with pytest.raises(ValueError, match="retention_policy_digest"): + replace(packet, retention_policy_digest="not-a-digest") + + +@pytest.mark.parametrize( + "reason_code", + ["jane_doe", "salary_120000", "race_gender_review", "candidate_alice_smith"], +) +def test_reason_code_rejects_personal_or_value_bearing_free_form_codes(reason_code: str) -> None: + """Prevent nominal governance reason metadata from becoming a candidate-data channel.""" + data = values() + data["reason_code"] = reason_code + with pytest.raises(ValueError): + CandidateEvidenceIntakePacket(**data) + + +def test_reason_code_replace_path_is_revalidated() -> None: + """Reject value-bearing reason metadata when copying an immutable intake packet.""" + packet = CandidateEvidenceIntakePacket(**values()) + with pytest.raises(ValueError): + replace(packet, reason_code="salary_120000") diff --git a/packages/candidate-evidence/tests/test_reference_privacy.py b/packages/candidate-evidence/tests/test_reference_privacy.py new file mode 100644 index 000000000..60ffdd031 --- /dev/null +++ b/packages/candidate-evidence/tests/test_reference_privacy.py @@ -0,0 +1,79 @@ +"""Privacy regressions for opaque candidate-evidence trust references.""" +from dataclasses import replace +from datetime import datetime, timezone + +import pytest + +from orgmetra_candidate_evidence import build_candidate_evidence_intake_packet + +UUID1_ID = "6ba7b810-9dad-11d1-80b4-00c04fd430c8" +UUID7_TENANT = "10000000-0000-7000-8000-000000000001" + + +def valid_kwargs() -> dict[str, object]: + """Return one complete packet whose trust references use opaque UUIDv4 suffixes.""" + return { + "tenant_record_id": "12345678-1234-4234-8234-123456789abc", + "intake_reference": "candidate_evidence_intake:11111111-1111-4111-8111-111111111111", + "candidate_profile_reference": "candidate_profile:22222222-2222-4222-8222-222222222222", + "requisition_reference": "requisition:33333333-3333-4333-8333-333333333333", + "job_profile_reference": "job_profile:44444444-4444-4444-8444-444444444444", + "job_requirements_reference": "job_requirements:55555555-5555-4555-8555-555555555555", + "job_requirements_digest": "a" * 64, + "evidence_set_reference": "evidence_set:66666666-6666-4666-8666-666666666666", + "evidence_set_digest": "b" * 64, + "source_provenance_reference": "source_provenance:77777777-7777-4777-8777-777777777777", + "source_provenance_digest": "c" * 64, + "handling_policy_reference": "handling_policy:88888888-8888-4888-8888-888888888888", + "handling_policy_digest": "d" * 64, + "retention_policy_reference": "retention_policy:99999999-9999-4999-8999-999999999999", + "retention_policy_digest": "e" * 64, + "actor_reference": "actor:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "evidence_item_count": 5, + "purpose_code": "candidate_evidence_intake", + "reason_code": "requisition_candidate_review", + "collected_at": datetime(2026, 8, 19, 1, 2, 3, 456789, tzinfo=timezone.utc), + } + + +@pytest.mark.parametrize( + ("field_name", "prefix"), + [ + ("intake_reference", "candidate_evidence_intake"), + ("candidate_profile_reference", "candidate_profile"), + ("requisition_reference", "requisition"), + ("job_profile_reference", "job_profile"), + ("job_requirements_reference", "job_requirements"), + ("evidence_set_reference", "evidence_set"), + ("source_provenance_reference", "source_provenance"), + ("handling_policy_reference", "handling_policy"), + ("retention_policy_reference", "retention_policy"), + ("actor_reference", "actor"), + ], +) +def test_uuid1_trust_reference_is_rejected_by_builder_and_replace( + field_name: str, + prefix: str, +) -> None: + """UUIDv1 timestamp/node metadata must never enter an opaque trust-reference field.""" + value = f"{prefix}:{UUID1_ID}" + kwargs = valid_kwargs() + kwargs[field_name] = value + with pytest.raises(ValueError, match=field_name): + build_candidate_evidence_intake_packet(**kwargs) + + packet = build_candidate_evidence_intake_packet(**valid_kwargs()) + with pytest.raises(ValueError, match=field_name): + replace(packet, **{field_name: value}) + + +def test_authoritative_uuid7_tenant_identity_is_accepted_by_builder_and_replace() -> None: + """The leaf packet must accept tenant UUIDs already valid in authoritative Orgmetra core.""" + kwargs = valid_kwargs() + kwargs["tenant_record_id"] = UUID7_TENANT + + packet = build_candidate_evidence_intake_packet(**kwargs) + replaced = replace(build_candidate_evidence_intake_packet(**valid_kwargs()), tenant_record_id=UUID7_TENANT) + + assert packet.tenant_record_id == UUID7_TENANT + assert replaced.tenant_record_id == UUID7_TENANT diff --git a/packages/candidate-evidence/tests/test_tenant_scope.py b/packages/candidate-evidence/tests/test_tenant_scope.py new file mode 100644 index 000000000..496dd338a --- /dev/null +++ b/packages/candidate-evidence/tests/test_tenant_scope.py @@ -0,0 +1,45 @@ +"""Tenant-scope regressions for governed candidate-evidence intake.""" + +from datetime import datetime, timezone + +from orgmetra_candidate_evidence import build_candidate_evidence_intake_packet + + +def _packet(): + """Build one valid candidate-evidence packet for boundary assertions.""" + return build_candidate_evidence_intake_packet( + tenant_record_id="12345678-1234-4234-8234-123456789abc", + intake_reference="candidate_evidence_intake:11111111-1111-4111-8111-111111111111", + candidate_profile_reference="candidate_profile:22222222-2222-4222-8222-222222222222", + requisition_reference="requisition:33333333-3333-4333-8333-333333333333", + job_profile_reference="job_profile:44444444-4444-4444-8444-444444444444", + job_requirements_reference="job_requirements:55555555-5555-4555-8555-555555555555", + job_requirements_digest="a" * 64, + evidence_set_reference="evidence_set:66666666-6666-4666-8666-666666666666", + evidence_set_digest="b" * 64, + source_provenance_reference="source_provenance:77777777-7777-4777-8777-777777777777", + source_provenance_digest="c" * 64, + handling_policy_reference="handling_policy:88888888-8888-4888-8888-888888888888", + handling_policy_digest="d" * 64, + retention_policy_reference="retention_policy:99999999-9999-4999-8999-999999999999", + retention_policy_digest="e" * 64, + actor_reference="actor:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + evidence_item_count=5, + purpose_code="candidate_evidence_intake", + reason_code="requisition_candidate_review", + collected_at=datetime(2026, 8, 19, 1, 2, 3, 456789, tzinfo=timezone.utc), + ) + + +def test_sealing_requires_every_reference_to_resolve_in_exact_tenant() -> None: + """Prevent cross-tenant evidence mixing behind syntactically valid opaque references.""" + action = _packet().next_action + tenant_clause = "Re-resolve every packet reference within tenant_record_id" + correlation_clause = "verify candidate, requisition, and Job correlation" + provenance_clause = "verify job relevance, source provenance" + sealing_clause = "authoritative evidence sealing" + + assert tenant_clause in action + assert action.index(tenant_clause) < action.index(correlation_clause) + assert action.index(correlation_clause) < action.index(provenance_clause) + assert action.index(provenance_clause) < action.index(sealing_clause)