diff --git a/.github/workflows/foundation-ci.yml b/.github/workflows/foundation-ci.yml index 6b475d6f2..ef069fdf3 100644 --- a/.github/workflows/foundation-ci.yml +++ b/.github/workflows/foundation-ci.yml @@ -59,6 +59,7 @@ jobs: - name: Run owned unit and service contracts once run: | PYTHONPATH=packages/candidate-evidence/src COVERAGE_FILE=/tmp/orgmetra-candidate-evidence.coverage python -m pytest -c packages/candidate-evidence/pyproject.toml packages/candidate-evidence/tests + PYTHONPATH=packages/compensation-change-review/src COVERAGE_FILE=/tmp/orgmetra-compensation-change-review.coverage python -m pytest -c packages/compensation-change-review/pyproject.toml packages/compensation-change-review/tests PYTHONPATH=packages/hris-kernel/src COVERAGE_FILE=/tmp/orgmetra-hris-kernel.coverage python -m pytest -c packages/hris-kernel/pyproject.toml packages/hris-kernel/tests PYTHONPATH=packages/keyverse-adapter/src COVERAGE_FILE=/tmp/orgmetra-keyverse-adapter.coverage python -m pytest -c packages/keyverse-adapter/pyproject.toml packages/keyverse-adapter/tests PYTHONPATH=packages/migration-adapter/src COVERAGE_FILE=/tmp/orgmetra-migration-adapter.coverage python -m pytest -c packages/migration-adapter/pyproject.toml packages/migration-adapter/tests diff --git a/docs/adr/0022-governed-compensation-change-review.md b/docs/adr/0022-governed-compensation-change-review.md new file mode 100644 index 000000000..22982ba31 --- /dev/null +++ b/docs/adr/0022-governed-compensation-change-review.md @@ -0,0 +1,62 @@ +# ADR 0022: Govern compensation changes before authoritative mutation + +- Status: Proposed (active PR only) +- Date: 2026-08-19 + +## Context + +Compensation changes are high-impact employment actions. A useful enterprise review boundary must correlate the proposed change to authoritative worker scope, the exact current/proposed compensation artifacts, the governing policy, pay-equity review, budget authorization, and payroll handoff without copying salary, wage, bonus, benefit, equity, protected-attribute, or free-form case values into portable evidence. + +Syntactically valid Person, Employment, Assignment, policy, actor, or compensation-artifact references do not prove tenant membership, worker scope, policy applicability, actor separation, or effective-date correctness. Opaque identifiers also remain sensitive correlating metadata even when direct identifiers and pay values are absent. The authoritative HRIS already owns tenant identity semantics: protected Orgmetra core accepts canonical non-sentinel operational UUIDs, including UUIDv7. This leaf package therefore must not narrow `tenant_record_id` to UUIDv4. UUIDv4 remains appropriate for packet-owned opaque references where this package owns the correlation-privacy contract. + +Recorded-time evidence also crosses an ownership boundary. Python permits a caller to attach a custom `tzinfo` object to an otherwise exact built-in `datetime`; that timezone object can be mutable. Retaining it inside an issued review packet would allow later caller-side state changes to rewrite the apparent UTC instant or invalidate the packet's creation-time digest. The packet therefore must resolve the input offset exactly once and retain only a detached built-in UTC `datetime` as its trust-bearing recorded-time value. + +Current primary-source context is recorded in `docs/doctoring/compensation-change-review-references.md`. ISO 30414:2025 provides current human-capital reporting/disclosure context; U.S. EEOC compensation guidance demonstrates why compensation governance must not turn protected-attribute review into uncontrolled evidence copying; U.S. Department of Labor FLSA recordkeeping guidance demonstrates the need for accountable wage/time records. These are governance inputs only, not certification or universal legal-compliance claims. + +## Decision + +Orgmetra will expose a value-minimized `CompensationChangeReviewPacket` before any authoritative compensation-related HRIS mutation or payroll execution. + +The packet binds one authoritative Orgmetra `tenant_record_id` that satisfies the protected-core canonical non-sentinel operational-UUID contract. Packet-owned opaque references for the compensation review, Person, Employment, active Assignment/Job/Position snapshot, current compensation snapshot, proposed compensation plan, compensation policy, pay-equity review, budget authorization, payroll handoff plan, requester, and reviewer remain canonical non-sentinel UUIDv4-backed namespaced references. This preserves interoperability with authoritative UUIDv7 tenant identities while preventing timestamp/node-derived correlation metadata from entering packet-owned references presented as opaque. Evidence artifacts carry independent lowercase SHA-256 digests; a bounded positive `evidence_version`, proposed business effective date, and precision-preserving recorded-time instant are part of canonical evidence. + +`generated_at` accepts only an exact built-in timezone-aware `datetime`. Construction obtains a concrete UTC offset once, fails closed when the offset is indeterminate, converts the wall time to the corresponding instant, and stores a detached exact built-in UTC `datetime` backed by `datetime.timezone.utc`. Canonical export therefore never depends on later mutation of a caller-owned timezone object. Datetime subclasses are rejected so caller-overridable conversion or formatting methods cannot forge recorded-time evidence. + +The packet deliberately excludes compensation values, protected-attribute values, free-form case narrative, credentials, and free-form model output. It explicitly acknowledges remaining personal-data correlation with `contains_personal_data = true`. + +Direct construction and replacement fail closed unless the packet remains: + +- `human_confirmation_required = true`; +- `decision_authority = human_review_only`; +- `review_state = requires_human_review`; +- `scope_verification_state = requires_authoritative_resolution`; +- `mutation_state = not_authorized_to_apply`; and +- `external_execution_state = not_authorized_to_execute`. + +Requester and reviewer opaque references must differ, but authoritative separation of duties requires both identities to be re-resolved inside the packet tenant immediately before approval. The host must also re-resolve every packet reference, prove Person-to-Employment and active Assignment/Job/Position scope, and verify the current compensation snapshot, proposed plan, exact compensation policy, pay-equity review, budget authorization, effective date, and payroll-handoff provenance without copying compensation or protected-attribute values into the packet. Neither authoritative tenant UUID syntax nor packet-owned UUIDv4 syntax proves tenant membership, actor identity, worker scope, policy applicability, or substantive correctness. + +Any authorized HRIS change must use the authoritative Orgmetra People boundary with its own purpose-bound authorization, idempotency, bitemporal persistence, and immutable audit/outbox evidence. Payroll execution remains behind the payroll owner's published contract. This package performs no foreign mutation and no direct cross-service application-table SQL. + +## Consequences + +### Positive + +- A review envelope cannot masquerade as compensation approval, an applied HRIS change, or completed payroll execution. +- Buyers can correlate exact policy/equity/budget/proposed-plan evidence while minimizing duplicated pay and protected-attribute values. +- Authoritative UUIDv7 tenant identities remain interoperable instead of being rejected by a leaf-only UUIDv4 policy. +- UUIDv1 timestamp/node correlation remains excluded from packet-owned namespaced trust references. +- Changes to governed evidence or `evidence_version` change the canonical packet digest. +- Recorded-time evidence is detached from caller-owned mutable timezone state before issuance, so a previously issued canonical payload cannot drift because an input timezone object later changes. +- Cross-tenant, wrong-worker, stale-policy, actor-separation, and effective-date questions remain explicit authoritative-resolution obligations instead of being inferred from reference syntax. + +### Trade-offs + +- The packet is not anonymous; opaque worker/evidence correlations still require purpose-bound access, retention, export, and audit controls. +- UUID syntax is not authorization, ownership, or relationship evidence; authoritative tenant identity policy and packet-owned reference privacy are intentionally separate ownership boundaries. +- A pay-equity evidence reference/digest proves which artifact was reviewed, not that compensation is fair, nondiscriminatory, or legally sufficient. +- The packet does not calculate compensation, payroll, taxes, protected-class statistics, pay-equity findings, or legal conclusions. +- Hosts must perform authoritative scope and actor resolution at approval time. +- The stored recorded-time representation is normalized to UTC; an input timezone's display-zone identity is intentionally not retained as trust-bearing evidence. + +## Verification + +The package requires exact 100% owned statement and branch coverage; beginner-readable module/class/callable docstrings; direct-construction and `dataclasses.replace(...)` fail-closed regressions; authoritative tenant interoperability with the protected-core canonical non-sentinel operational-UUID contract, including the canonical UUIDv7 tenant used by the core PostgreSQL regression and rejection of RFC 9562 Nil/Max sentinels; strict UUIDv4 packet-owned namespaced references including UUIDv1 rejection; lowercase SHA-256 evidence; redacted `repr`; closed non-sensitive reason categories; bounded evidence versions; business-date validation; exact built-in timezone-aware timestamp validation; one-time offset resolution with detached UTC storage; indeterminate-offset and datetime-subclass rejection; mutable-timezone post-issuance regression; deterministic canonical JSON/digest evidence; separate requester/reviewer references plus explicit authoritative identity re-resolution; and immutable human-review/no-mutation/no-execution states. diff --git a/docs/doctoring/compensation-change-review-references.md b/docs/doctoring/compensation-change-review-references.md new file mode 100644 index 000000000..9023334f0 --- /dev/null +++ b/docs/doctoring/compensation-change-review-references.md @@ -0,0 +1,15 @@ +# Compensation change review references + +Verified/retrieved: August 19, 2026. + +## APA 7 references + +International Organization for Standardization. (2025). *ISO 30414:2025 Human resource management — Requirements and recommendations for human capital reporting and disclosure* (2nd ed.). https://www.iso.org/standard/30414 + +U.S. Equal Employment Opportunity Commission. (2000). *Section 10: Compensation discrimination*. https://www.eeoc.gov/laws/guidance/section-10-compensation-discrimination + +U.S. Department of Labor, Wage and Hour Division. (n.d.). *Fact Sheet #21: Recordkeeping requirements under the Fair Labor Standards Act (FLSA).* Retrieved August 19, 2026, from https://www.dol.gov/agencies/whd/fact-sheets/21-flsa-recordkeeping + +## Applied boundary + +These sources support a conservative governance boundary: compensation evidence should be versioned, attributable, reviewable, and minimized rather than copied into a broad review envelope. U.S. EEOC and DOL material is jurisdiction-specific context, not a universal rule engine. This package does not infer discrimination, determine legal compliance, calculate pay, perform payroll, or claim ISO certification. It binds only the exact reviewed policy/equity/budget/compensation-plan artifacts and preserves accountable human review before authoritative action. diff --git a/docs/traceability/compensation-change-review.md b/docs/traceability/compensation-change-review.md new file mode 100644 index 000000000..ac34c72cb --- /dev/null +++ b/docs/traceability/compensation-change-review.md @@ -0,0 +1,23 @@ +# Compensation change review traceability + +Status: **active PR only / proposed**, not protected-`develop` product truth until merged. + +| Buyer or governance need | Contract | Executable evidence | +| --- | --- | --- | +| Correlate a proposed pay change without copying pay amounts | Opaque Person/Employment/current/proposed compensation references + independent digests; compensation/protected values excluded | `test_builds_deterministic_value_minimized_packet`, `test_every_reference_and_digest_is_validated` | +| Preserve authoritative tenant identity while preventing leaf-owned reference correlation smuggling | `tenant_record_id` follows protected HRIS canonical non-sentinel operational UUID semantics; packet-owned namespaced trust references require canonical UUIDv4 | `test_accepts_authoritative_operational_uuidv7_tenant_identity`, `test_rejects_reserved_sentinel_tenant_identity`, `test_invalid_core_inputs_fail_closed`, `test_every_reference_and_digest_is_validated`, `test_uuid1_trust_reference_is_rejected_by_builder_and_replace` | +| Preserve accountable separation of duties | Requester/reviewer references differ; canonical next action requires tenant-scoped identity re-resolution and distinct resolved actors | `test_same_opaque_actor_reference_is_rejected_early`, `test_next_action_requires_identity_scope_and_evidence_before_approval` | +| Prevent review evidence from authorizing a high-impact action | Human-only review, unresolved authoritative scope, no HRIS mutation, no external execution are immutable | `test_direct_constructor_and_replace_fail_closed` | +| Bind policy, equity, budget, payroll handoff, and evidence version | Independent SHA-256 digests and bounded positive `evidence_version` are canonical | `test_canonical_evidence_changes_with_governed_artifact_or_version`, `test_invalid_core_inputs_fail_closed` | +| Prevent an issued packet from emitting rewritten compensation evidence | live object identity is bound to construction-time canonical SHA-256; canonical export snapshots once and rejects later valid-value rewrites or unsupported copies | `test_valid_value_mutation_cannot_rewrite_emitted_evidence`, `test_shallow_copy_does_not_inherit_process_local_issuance_evidence`, `test_collected_packet_releases_process_local_issuance_binding` | +| Avoid accidental logging of worker/pay correlations | Generated dataclass representation disabled; custom representation fully redacted | `test_repr_redacts_personal_and_compensation_correlations` | +| Preserve exact audit time semantics without retaining caller-owned mutable timezone state | Exact built-in timezone-aware `datetime` input is resolved once, detached into a built-in UTC `datetime`, and then used for canonical evidence; unresolved offsets and hostile datetime subclasses fail closed | `test_fractional_and_offset_timestamps_preserve_distinct_instants`, `test_timezone_with_unknown_offset_is_rejected`, `test_rejects_datetime_subclasses_that_can_forge_recorded_time_evidence`, `test_rejects_timezone_without_resolvable_utc_offset`, `test_freezes_mutable_timezone_before_issuing_recorded_time_evidence` | +| Keep package-quality evidence current without recreating leaf CI | Canonical Foundation CI invokes the compensation-review package suite directly on every `develop` PR and the package-specific workflow remains absent | `tests/test_quality_workflow_trigger.py`, `tests.test_github_actions_runner_image`; supplemental to central required workflows | + +## Quality gate + +`.github/workflows/foundation-ci.yml` checks out the exact PR head on the explicit `ubuntu-24.04` runner, uses the repository's hash-pinned reviewed Python test toolchain, and invokes the compensation-review package suite directly. The package's `pyproject.toml` continues to require exact 100% statement and branch coverage. `.github/workflows/compensation-change-review-quality.yml` is deliberately absent after repository-quality consolidation; executable regressions reject its reintroduction rather than duplicating the canonical Foundation lane. + +## Ownership boundary + +This slice adds no database migration, compensation calculation, payroll implementation, protected-attribute analytics, direct cross-service SQL, or competing People mutation surface. Authoritative tenant identity plus Person/Employment/Assignment scope and subsequent HRIS mutation remain Orgmetra core responsibilities; external payroll work remains behind its published owner contract. UUIDv4 is an opacity/privacy constraint for packet-owned namespaced references only and does not establish tenant membership, actor identity, worker relationship, or policy applicability. The process-local issuance registry protects only the live Python object and is not durable uniqueness, authorization, attestation, or a replacement for immutable audit/outbox persistence. `docs/doctoring/compensation-change-review-references.md` records current primary-source context without promoting the package into a legal-compliance engine. diff --git a/manifest.json b/manifest.json index f7b6cf55e..0d6e4531b 100644 --- a/manifest.json +++ b/manifest.json @@ -5,9 +5,9 @@ "files": [ { "path": ".github/workflows/foundation-ci.yml", - "sha256": "b6a4365936b66803a8112f034c77d53d33301a7a798ed4f68746a4f2d8b081d7", - "bytes": 6651, - "lines": 125 + "sha256": "972ef8929abe93342eb11d000c490e397b44e6e34ddb41d317383ff67067ffe8", + "bytes": 6889, + "lines": 126 }, { "path": ".gitignore", diff --git a/packages/compensation-change-review/CHANGELOG.md b/packages/compensation-change-review/CHANGELOG.md new file mode 100644 index 000000000..345fbabb5 --- /dev/null +++ b/packages/compensation-change-review/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## [Unreleased] + +### Added + +- `CompensationChangeReviewPacket`, a value-minimized pre-mutation evidence contract that binds authoritative worker scope, reviewed compensation-plan/policy evidence, pay-equity review, budget authorization, and payroll handoff provenance without copying compensation or protected-attribute values. +- Fail-closed human-review, authoritative-resolution, no-HRIS-mutation, and no-payroll-execution states with separate requester/reviewer actor references and bounded evidence versions. +- Deterministic canonical JSON/SHA-256 evidence, redacted representations, canonical non-sentinel UUIDv4 identities for packet-owned namespaced opaque trust references, and exact 100% owned statement/branch coverage regressions. +- Creation-bound process-local evidence integrity: low-level valid-value field rewrites and unsupported object copies cannot emit a second valid-looking canonical compensation-review truth. +- Compensation-review quality now runs inside canonical `Foundation CI`; the package-specific workflow is retired after protected repository-quality consolidation, while the package `pyproject.toml` continues to enforce exact 100% statement and branch coverage. + +### Changed + +- `tenant_record_id` now follows the authoritative Orgmetra canonical non-sentinel operational-UUID contract instead of imposing a duplicate UUIDv4-only leaf policy; the protected-core UUIDv7 tenant form is covered explicitly while RFC 9562 Nil/Max sentinels remain rejected. +- Canonical export now snapshots all trust-bearing fields once and verifies the exact snapshot against the packet's construction-time SHA-256 seal before returning evidence. This process-local seal is defense in depth only and does not replace durable authorization, signatures, audit/outbox persistence, or correlation uniqueness. +- Digest evidence now rejects `str` subclasses so packet fields cannot retain caller-defined runtime behavior. +- Recorded-time evidence now resolves the input UTC offset once and stores a detached exact built-in UTC `datetime`; caller-owned mutable timezone state cannot rewrite or invalidate an already-issued packet, while indeterminate offsets and datetime subclasses fail closed. diff --git a/packages/compensation-change-review/README.md b/packages/compensation-change-review/README.md new file mode 100644 index 000000000..44194a1d0 --- /dev/null +++ b/packages/compensation-change-review/README.md @@ -0,0 +1,61 @@ +# Orgmetra Compensation Change Review + +`orgmetra-compensation-change-review` defines a portable, value-minimized evidence envelope for reviewing a proposed compensation change before any authoritative HRIS mutation or payroll execution. + +## What the packet carries + +The packet correlates one authoritative Orgmetra tenant, Person, Employment, active Assignment/Job/Position scope snapshot, current compensation snapshot, proposed compensation plan, exact compensation policy, pay-equity review, budget authorization, payroll handoff plan, requester, reviewer, business effective date, and evidence version. `tenant_record_id` follows the authoritative HRIS canonical non-sentinel operational-UUID contract, so valid tenant UUID versions accepted by protected Orgmetra core remain interoperable. Packet-owned namespaced trust-bearing references use canonical non-sentinel UUIDv4 identities so timestamp/node-derived correlation metadata cannot enter leaf-owned opaque references. Evidence artifacts also carry independent lowercase SHA-256 digests. + +The envelope intentionally **does not carry salary, wage, bonus, benefit, equity, protected-attribute, credential, or free-form case/model values**. Opaque Person/Employment and evidence references still create sensitive personal-data correlation, so the packet explicitly reports `contains_personal_data = true` rather than claiming anonymity. + +Recorded-time evidence is normalized at construction: Orgmetra resolves the supplied aware `datetime` to one concrete instant and stores a detached exact built-in UTC `datetime`. This means an application-owned mutable timezone object cannot later rewrite or invalidate an already-issued packet. Inputs whose UTC offset cannot be resolved, and `datetime` subclasses that could override trust-bearing conversion or formatting behavior, fail closed. + +## Human and system boundary + +A valid packet is not an approval. It remains: + +- `decision_authority = human_review_only`; +- `review_state = requires_human_review`; +- `scope_verification_state = requires_authoritative_resolution`; +- `mutation_state = not_authorized_to_apply`; and +- `external_execution_state = not_authorized_to_execute`. + +Immediately before approval, the host must re-resolve every reference in the packet tenant, prove requester/reviewer resolve to distinct authoritative actor identities, prove the Person-to-Employment and active Assignment/Job/Position scope, and verify the exact current/proposed compensation evidence, compensation policy, pay-equity review, budget authorization, effective date, and payroll-handoff provenance. UUID syntax is not authorization or relationship evidence: the tenant UUID is governed by authoritative Orgmetra core, while UUIDv4 on packet-owned references is only an opacity/privacy constraint. Any authorized HRIS change then goes through Orgmetra's authoritative People boundary. Payroll execution remains behind the payroll owner's published contract. + +Each live packet instance is also bound to the SHA-256 of its exact canonical evidence at construction. `canonical_json()` snapshots every trust-bearing field once, verifies that exact snapshot against the construction-time seal, and returns the same verified snapshot. A low-level valid-value rewrite therefore fails closed rather than emitting a second audit truth, and an unsupported shallow-copied instance has no issuance binding. This is process-local defense in depth only: it is not a signature, durable uniqueness constraint, authorization token, or substitute for immutable audit/outbox persistence. + +## Example + +```python +from datetime import date, datetime, timezone +from orgmetra_compensation_change_review import build_compensation_change_review_packet + +packet = build_compensation_change_review_packet( + tenant_record_id="10000000-0000-7000-8000-000000000001", + compensation_review_reference="compensation_change_review:22222222-2222-4222-8222-222222222222", + person_record_reference="person_record:33333333-3333-4333-8333-333333333333", + employment_record_reference="employment_record:44444444-4444-4444-8444-444444444444", + active_assignment_snapshot_reference="active_assignment_snapshot:55555555-5555-4555-8555-555555555555", + active_assignment_snapshot_digest="a" * 64, + current_compensation_snapshot_reference="compensation_snapshot:66666666-6666-4666-8666-666666666666", + current_compensation_snapshot_digest="b" * 64, + proposed_compensation_plan_reference="compensation_plan:77777777-7777-4777-8777-777777777777", + proposed_compensation_plan_digest="c" * 64, + compensation_policy_reference="compensation_policy:88888888-8888-4888-8888-888888888888", + compensation_policy_digest="d" * 64, + pay_equity_review_reference="pay_equity_review:99999999-9999-4999-8999-999999999999", + pay_equity_review_digest="e" * 64, + budget_authorization_reference="budget_authorization:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + budget_authorization_digest="f" * 64, + payroll_handoff_plan_reference="payroll_handoff_plan:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + payroll_handoff_plan_digest="1" * 64, + requester_reference="actor:cccccccc-cccc-4ccc-8ccc-cccccccccccc", + reviewer_reference="actor:dddddddd-dddd-4ddd-8ddd-dddddddddddd", + purpose_code="compensation_change_review", + reason_code="annual_compensation_review", + proposed_effective_on=date(2026, 10, 1), + generated_at=datetime.now(timezone.utc), +) +``` + +`packet.sha256_digest()` is immutable correlation evidence only. It is not proof that pay is fair, lawful, budgeted, approved, applied in the HRIS, or executed in payroll. diff --git a/packages/compensation-change-review/pyproject.toml b/packages/compensation-change-review/pyproject.toml new file mode 100644 index 000000000..b9e2c44b3 --- /dev/null +++ b/packages/compensation-change-review/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "orgmetra-compensation-change-review" +version = "0.1.0" +description = "Governed pre-mutation compensation change review evidence 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_compensation_change_review", + "--cov-branch", + "--cov-report=term-missing", + "--cov-fail-under=100", +] diff --git a/packages/compensation-change-review/src/orgmetra_compensation_change_review/__init__.py b/packages/compensation-change-review/src/orgmetra_compensation_change_review/__init__.py new file mode 100644 index 000000000..55aa42147 --- /dev/null +++ b/packages/compensation-change-review/src/orgmetra_compensation_change_review/__init__.py @@ -0,0 +1,5 @@ +"""Governed compensation-change review contracts for Orgmetra.""" + +from .packet import CompensationChangeReviewPacket, build_compensation_change_review_packet + +__all__ = ["CompensationChangeReviewPacket", "build_compensation_change_review_packet"] diff --git a/packages/compensation-change-review/src/orgmetra_compensation_change_review/packet.py b/packages/compensation-change-review/src/orgmetra_compensation_change_review/packet.py new file mode 100644 index 000000000..692fa6798 --- /dev/null +++ b/packages/compensation-change-review/src/orgmetra_compensation_change_review/packet.py @@ -0,0 +1,408 @@ +"""Governed, value-minimized pre-mutation compensation-change review evidence. + +The packet correlates one proposed compensation change to authoritative worker scope and +exact reviewed policy/evidence artifacts without copying pay amounts, protected-attribute +values, narrative case material, credentials, or model output into the review envelope. +Opaque worker and evidence references remain sensitive correlating metadata. Authoritative +scope resolution, human approval, HRIS mutation, and payroll execution remain outside this +package and must occur through their published owner boundaries. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timezone +from hashlib import sha256 +import json +import re +from threading import RLock +from uuid import UUID +from weakref import ref + +_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 = "compensation_change_review" +_ALLOWED_REASON_CODES = frozenset( + { + "annual_compensation_review", + "promotion_compensation_review", + "market_adjustment_review", + "retention_adjustment_review", + "role_change_compensation_review", + } +) +_DECISION_AUTHORITY = "human_review_only" +_REVIEW_STATE = "requires_human_review" +_SCOPE_VERIFICATION_STATE = "requires_authoritative_resolution" +_MUTATION_STATE = "not_authorized_to_apply" +_EXTERNAL_EXECUTION_STATE = "not_authorized_to_execute" +_NEXT_ACTION = ( + "Re-resolve every packet reference within tenant_record_id; specifically re-resolve " + "requester_reference and reviewer_reference within tenant_record_id and verify their " + "resolved actor identities are distinct, then prove the Person-to-Employment binding " + "and active Assignment/Job/Position scope represented by the snapshot, then verify the " + "current compensation snapshot, proposed compensation plan, exact compensation policy, " + "pay-equity review, budget authorization, proposed effective date, and payroll-handoff " + "provenance without copying compensation or protected-attribute values into this packet; " + "then record accountable human approval, apply any authorized HRIS change only through " + "the authoritative Orgmetra People boundary, and execute payroll only through its " + "published owner contract." +) +_ISSUANCE_LOCK = RLock() +_ISSUANCE_DIGESTS: dict[int, tuple[object, str]] = {} + + +def _validate_operational_uuid(value: str, field_name: str) -> None: + """Require canonical non-sentinel UUID text owned by the authoritative HRIS.""" + if type(value) is not str: + raise ValueError(f"{field_name} must be canonical UUID text") + try: + parsed = UUID(value) + except (ValueError, AttributeError, TypeError) as exc: + raise ValueError(f"{field_name} must be canonical UUID text") from exc + if str(parsed) != value or parsed.int in (0, (1 << 128) - 1): + raise ValueError(f"{field_name} must be a canonical operational UUID") + + +def _validate_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 ( + type(value) is not str + or len(value) > 160 + or not _REFERENCE_PATTERN.fullmatch(value) + or not value.startswith(f"{prefix}:") + ): + raise ValueError(message) + suffix = value.split(":", 1)[1] + try: + parsed = UUID(suffix) + except (ValueError, AttributeError, TypeError) as exc: + raise ValueError(message) from exc + if str(parsed) != suffix or parsed.version != 4 or parsed.int in (0, (1 << 128) - 1): + raise ValueError(message) + + +def _validate_digest(value: str, field_name: str) -> None: + """Require lowercase SHA-256 hexadecimal evidence.""" + if type(value) is not str or not _DIGEST_PATTERN.fullmatch(value): + raise ValueError(f"{field_name} must be lowercase SHA-256 hex") + + +def _freeze_recorded_timestamp(value: datetime) -> datetime: + """Detach one recorded-time instant from caller-owned timezone state.""" + if type(value) is not datetime or value.tzinfo is None: + raise ValueError("generated_at must be timezone-aware exact datetime") + offset = value.utcoffset() + if offset is None: + raise ValueError("generated_at must be timezone-aware exact datetime") + utc_naive = value.replace(tzinfo=None) - offset + return utc_naive.replace(tzinfo=timezone.utc) + + +def _canonical_timestamp(value: datetime) -> str: + """Render an aware instant as precision-preserving UTC RFC 3339 text.""" + if type(value) is not datetime or value.tzinfo is None or value.utcoffset() is None: + raise ValueError("generated_at must be timezone-aware exact datetime") + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _validate_business_date(value: date, field_name: str) -> None: + """Require a business date rather than a datetime or textual date.""" + if type(value) is not date: + raise ValueError(f"{field_name} must be a date") + + +def _validate_evidence_version(value: int) -> None: + """Require a bounded positive integer version for high-impact review evidence.""" + if type(value) is not int or value < 1 or value > 2_147_483_647: + raise ValueError("evidence_version must be an integer from 1 through 2147483647") + + +def _canonical_json(payload: dict[str, object]) -> str: + """Serialize one already-snapshotted evidence payload deterministically.""" + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _seal_issuance(packet: object, canonical: str) -> None: + """Bind one live packet identity to its creation-time canonical evidence digest.""" + digest = sha256(canonical.encode("utf-8")).hexdigest() + packet_id = id(packet) + + def release_issuance(_packet_reference: object) -> None: + """Release the process-local seal when its packet is collected.""" + with _ISSUANCE_LOCK: + _ISSUANCE_DIGESTS.pop(packet_id, None) + + packet_reference = ref(packet, release_issuance) + with _ISSUANCE_LOCK: + _ISSUANCE_DIGESTS[packet_id] = (packet_reference, digest) + + +def _assert_issuance_integrity(packet: object, canonical: str) -> None: + """Fail closed when a live or copied packet differs from issued evidence.""" + digest = sha256(canonical.encode("utf-8")).hexdigest() + with _ISSUANCE_LOCK: + sealed = _ISSUANCE_DIGESTS.get(id(packet)) + if sealed is None: + raise ValueError("compensation review evidence integrity check failed") + if sealed[1] != digest: + raise ValueError("compensation review evidence integrity check failed") + + +@dataclass(frozen=True, slots=True, repr=False, weakref_slot=True) +class CompensationChangeReviewPacket: + """Immutable compensation-review evidence that cannot authorize mutation or payroll.""" + + tenant_record_id: str + compensation_review_reference: str + person_record_reference: str + employment_record_reference: str + active_assignment_snapshot_reference: str + active_assignment_snapshot_digest: str + current_compensation_snapshot_reference: str + current_compensation_snapshot_digest: str + proposed_compensation_plan_reference: str + proposed_compensation_plan_digest: str + compensation_policy_reference: str + compensation_policy_digest: str + pay_equity_review_reference: str + pay_equity_review_digest: str + budget_authorization_reference: str + budget_authorization_digest: str + payroll_handoff_plan_reference: str + payroll_handoff_plan_digest: str + requester_reference: str + reviewer_reference: str + purpose_code: str + reason_code: str + proposed_effective_on: date + generated_at: datetime + evidence_version: int = 1 + contains_personal_data: bool = True + contains_compensation_values: bool = False + contains_protected_attribute_values: bool = False + contains_free_form_case_narrative: bool = False + contains_free_form_model_output: bool = False + human_confirmation_required: bool = True + decision_authority: str = _DECISION_AUTHORITY + review_state: str = _REVIEW_STATE + scope_verification_state: str = _SCOPE_VERIFICATION_STATE + mutation_state: str = _MUTATION_STATE + external_execution_state: str = _EXTERNAL_EXECUTION_STATE + next_action: str = _NEXT_ACTION + + def __repr__(self) -> str: + """Return a representation that never emits sensitive correlation evidence.""" + return "CompensationChangeReviewPacket()" + + 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.compensation_review_reference, + "compensation_change_review", + "compensation_review_reference", + ) + _validate_reference(self.person_record_reference, "person_record", "person_record_reference") + _validate_reference( + self.employment_record_reference, + "employment_record", + "employment_record_reference", + ) + _validate_reference( + self.active_assignment_snapshot_reference, + "active_assignment_snapshot", + "active_assignment_snapshot_reference", + ) + _validate_digest(self.active_assignment_snapshot_digest, "active_assignment_snapshot_digest") + _validate_reference( + self.current_compensation_snapshot_reference, + "compensation_snapshot", + "current_compensation_snapshot_reference", + ) + _validate_digest( + self.current_compensation_snapshot_digest, + "current_compensation_snapshot_digest", + ) + _validate_reference( + self.proposed_compensation_plan_reference, + "compensation_plan", + "proposed_compensation_plan_reference", + ) + _validate_digest( + self.proposed_compensation_plan_digest, + "proposed_compensation_plan_digest", + ) + _validate_reference( + self.compensation_policy_reference, + "compensation_policy", + "compensation_policy_reference", + ) + _validate_digest(self.compensation_policy_digest, "compensation_policy_digest") + _validate_reference( + self.pay_equity_review_reference, + "pay_equity_review", + "pay_equity_review_reference", + ) + _validate_digest(self.pay_equity_review_digest, "pay_equity_review_digest") + _validate_reference( + self.budget_authorization_reference, + "budget_authorization", + "budget_authorization_reference", + ) + _validate_digest(self.budget_authorization_digest, "budget_authorization_digest") + _validate_reference( + self.payroll_handoff_plan_reference, + "payroll_handoff_plan", + "payroll_handoff_plan_reference", + ) + _validate_digest(self.payroll_handoff_plan_digest, "payroll_handoff_plan_digest") + _validate_reference(self.requester_reference, "actor", "requester_reference") + _validate_reference(self.reviewer_reference, "actor", "reviewer_reference") + if self.requester_reference == self.reviewer_reference: + raise ValueError("requester and reviewer must be different actor references") + if type(self.purpose_code) is not str or self.purpose_code != _PURPOSE_CODE: + raise ValueError("purpose_code must remain compensation_change_review") + if type(self.reason_code) is not str or self.reason_code not in _ALLOWED_REASON_CODES: + raise ValueError("reason_code must be an approved non-sensitive compensation-review category") + _validate_business_date(self.proposed_effective_on, "proposed_effective_on") + object.__setattr__(self, "generated_at", _freeze_recorded_timestamp(self.generated_at)) + _canonical_timestamp(self.generated_at) + _validate_evidence_version(self.evidence_version) + if self.contains_personal_data is not True: + raise ValueError("compensation review packet must acknowledge sensitive personal-data correlation") + if self.contains_compensation_values is not False: + raise ValueError("compensation review packet must not contain compensation values") + if self.contains_protected_attribute_values is not False: + raise ValueError("compensation review packet must not contain protected-attribute values") + if self.contains_free_form_case_narrative is not False: + raise ValueError("compensation review packet must not contain free-form case narrative") + if self.contains_free_form_model_output is not False: + raise ValueError("compensation review packet must not contain free-form model output") + if self.human_confirmation_required is not True: + raise ValueError("human confirmation is mandatory before a compensation change") + if type(self.decision_authority) is not str or self.decision_authority != _DECISION_AUTHORITY: + raise ValueError("decision_authority must remain human_review_only") + if type(self.review_state) is not str or self.review_state != _REVIEW_STATE: + raise ValueError("review_state must remain requires_human_review") + if type(self.scope_verification_state) is not str or self.scope_verification_state != _SCOPE_VERIFICATION_STATE: + raise ValueError("scope_verification_state must remain requires_authoritative_resolution") + if type(self.mutation_state) is not str or self.mutation_state != _MUTATION_STATE: + raise ValueError("mutation_state must remain not_authorized_to_apply") + if type(self.external_execution_state) is not str or self.external_execution_state != _EXTERNAL_EXECUTION_STATE: + raise ValueError("external_execution_state must remain not_authorized_to_execute") + if type(self.next_action) is not str or self.next_action != _NEXT_ACTION: + raise ValueError("next_action must remain the governed compensation-change instruction") + _seal_issuance(self, _canonical_json(self._canonical_payload())) + + def _canonical_payload(self) -> dict[str, object]: + """Snapshot every trust-bearing field once for integrity verification and export.""" + return { + "active_assignment_snapshot_digest": self.active_assignment_snapshot_digest, + "active_assignment_snapshot_reference": self.active_assignment_snapshot_reference, + "budget_authorization_digest": self.budget_authorization_digest, + "budget_authorization_reference": self.budget_authorization_reference, + "compensation_policy_digest": self.compensation_policy_digest, + "compensation_policy_reference": self.compensation_policy_reference, + "compensation_review_reference": self.compensation_review_reference, + "contains_compensation_values": self.contains_compensation_values, + "contains_free_form_case_narrative": self.contains_free_form_case_narrative, + "contains_free_form_model_output": self.contains_free_form_model_output, + "contains_personal_data": self.contains_personal_data, + "contains_protected_attribute_values": self.contains_protected_attribute_values, + "current_compensation_snapshot_digest": self.current_compensation_snapshot_digest, + "current_compensation_snapshot_reference": self.current_compensation_snapshot_reference, + "decision_authority": self.decision_authority, + "employment_record_reference": self.employment_record_reference, + "evidence_version": self.evidence_version, + "external_execution_state": self.external_execution_state, + "generated_at": _canonical_timestamp(self.generated_at), + "human_confirmation_required": self.human_confirmation_required, + "mutation_state": self.mutation_state, + "next_action": self.next_action, + "pay_equity_review_digest": self.pay_equity_review_digest, + "pay_equity_review_reference": self.pay_equity_review_reference, + "payroll_handoff_plan_digest": self.payroll_handoff_plan_digest, + "payroll_handoff_plan_reference": self.payroll_handoff_plan_reference, + "person_record_reference": self.person_record_reference, + "proposed_compensation_plan_digest": self.proposed_compensation_plan_digest, + "proposed_compensation_plan_reference": self.proposed_compensation_plan_reference, + "proposed_effective_on": self.proposed_effective_on.isoformat(), + "purpose_code": self.purpose_code, + "reason_code": self.reason_code, + "requester_reference": self.requester_reference, + "review_state": self.review_state, + "reviewer_reference": self.reviewer_reference, + "scope_verification_state": self.scope_verification_state, + "tenant_record_id": self.tenant_record_id, + } + + def canonical_json(self) -> str: + """Return creation-bound deterministic canonical JSON for immutable audit correlation.""" + canonical = _canonical_json(self._canonical_payload()) + _assert_issuance_integrity(self, canonical) + return canonical + + def sha256_digest(self) -> str: + """Return SHA-256 over the exact canonical UTF-8 compensation-review packet.""" + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +def build_compensation_change_review_packet( + *, + tenant_record_id: str, + compensation_review_reference: str, + person_record_reference: str, + employment_record_reference: str, + active_assignment_snapshot_reference: str, + active_assignment_snapshot_digest: str, + current_compensation_snapshot_reference: str, + current_compensation_snapshot_digest: str, + proposed_compensation_plan_reference: str, + proposed_compensation_plan_digest: str, + compensation_policy_reference: str, + compensation_policy_digest: str, + pay_equity_review_reference: str, + pay_equity_review_digest: str, + budget_authorization_reference: str, + budget_authorization_digest: str, + payroll_handoff_plan_reference: str, + payroll_handoff_plan_digest: str, + requester_reference: str, + reviewer_reference: str, + purpose_code: str, + reason_code: str, + proposed_effective_on: date, + generated_at: datetime, + evidence_version: int = 1, +) -> CompensationChangeReviewPacket: + """Build a value-minimized compensation packet pending authoritative human approval.""" + return CompensationChangeReviewPacket( + tenant_record_id=tenant_record_id, + compensation_review_reference=compensation_review_reference, + person_record_reference=person_record_reference, + employment_record_reference=employment_record_reference, + active_assignment_snapshot_reference=active_assignment_snapshot_reference, + active_assignment_snapshot_digest=active_assignment_snapshot_digest, + current_compensation_snapshot_reference=current_compensation_snapshot_reference, + current_compensation_snapshot_digest=current_compensation_snapshot_digest, + proposed_compensation_plan_reference=proposed_compensation_plan_reference, + proposed_compensation_plan_digest=proposed_compensation_plan_digest, + compensation_policy_reference=compensation_policy_reference, + compensation_policy_digest=compensation_policy_digest, + pay_equity_review_reference=pay_equity_review_reference, + pay_equity_review_digest=pay_equity_review_digest, + budget_authorization_reference=budget_authorization_reference, + budget_authorization_digest=budget_authorization_digest, + payroll_handoff_plan_reference=payroll_handoff_plan_reference, + payroll_handoff_plan_digest=payroll_handoff_plan_digest, + requester_reference=requester_reference, + reviewer_reference=reviewer_reference, + purpose_code=purpose_code, + reason_code=reason_code, + proposed_effective_on=proposed_effective_on, + generated_at=generated_at, + evidence_version=evidence_version, + ) diff --git a/packages/compensation-change-review/tests/conftest.py b/packages/compensation-change-review/tests/conftest.py new file mode 100644 index 000000000..b40e07241 --- /dev/null +++ b/packages/compensation-change-review/tests/conftest.py @@ -0,0 +1,37 @@ +"""Shared pytest fixtures for governed compensation-change review contracts.""" + +from datetime import date, datetime, timezone + +import pytest + + +@pytest.fixture +def valid_packet_kwargs() -> dict[str, object]: + """Return one fresh complete valid packet input set with opaque identities.""" + return { + "tenant_record_id": "11111111-1111-4111-8111-111111111111", + "compensation_review_reference": "compensation_change_review:22222222-2222-4222-8222-222222222222", + "person_record_reference": "person_record:33333333-3333-4333-8333-333333333333", + "employment_record_reference": "employment_record:44444444-4444-4444-8444-444444444444", + "active_assignment_snapshot_reference": "active_assignment_snapshot:55555555-5555-4555-8555-555555555555", + "active_assignment_snapshot_digest": "a" * 64, + "current_compensation_snapshot_reference": "compensation_snapshot:66666666-6666-4666-8666-666666666666", + "current_compensation_snapshot_digest": "b" * 64, + "proposed_compensation_plan_reference": "compensation_plan:77777777-7777-4777-8777-777777777777", + "proposed_compensation_plan_digest": "c" * 64, + "compensation_policy_reference": "compensation_policy:88888888-8888-4888-8888-888888888888", + "compensation_policy_digest": "d" * 64, + "pay_equity_review_reference": "pay_equity_review:99999999-9999-4999-8999-999999999999", + "pay_equity_review_digest": "e" * 64, + "budget_authorization_reference": "budget_authorization:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "budget_authorization_digest": "f" * 64, + "payroll_handoff_plan_reference": "payroll_handoff_plan:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "payroll_handoff_plan_digest": "1" * 64, + "requester_reference": "actor:cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "reviewer_reference": "actor:dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "purpose_code": "compensation_change_review", + "reason_code": "annual_compensation_review", + "proposed_effective_on": date(2026, 10, 1), + "generated_at": datetime(2026, 8, 19, 6, 12, 13, 456789, tzinfo=timezone.utc), + "evidence_version": 1, + } diff --git a/packages/compensation-change-review/tests/test_packet.py b/packages/compensation-change-review/tests/test_packet.py new file mode 100644 index 000000000..63a4adab7 --- /dev/null +++ b/packages/compensation-change-review/tests/test_packet.py @@ -0,0 +1,222 @@ +"""Regression coverage for governed compensation-change review evidence.""" +from dataclasses import replace +from datetime import datetime, timedelta, timezone, tzinfo +import json +import re + +import pytest + +from orgmetra_compensation_change_review import ( + CompensationChangeReviewPacket, + build_compensation_change_review_packet, +) + + +def build_valid(valid_packet_kwargs: dict[str, object]) -> CompensationChangeReviewPacket: + """Build one valid packet through the supported public builder.""" + return build_compensation_change_review_packet(**valid_packet_kwargs) + + +def test_builds_deterministic_value_minimized_packet(valid_packet_kwargs: dict[str, object]) -> None: + """A valid packet is canonical, human-only, unresolved, and excludes protected values.""" + packet = build_valid(valid_packet_kwargs) + canonical = json.loads(packet.canonical_json()) + + assert packet.decision_authority == "human_review_only" + assert packet.review_state == "requires_human_review" + assert packet.scope_verification_state == "requires_authoritative_resolution" + assert packet.mutation_state == "not_authorized_to_apply" + assert packet.external_execution_state == "not_authorized_to_execute" + assert packet.contains_personal_data is True + assert packet.contains_compensation_values is False + assert packet.contains_protected_attribute_values is False + assert canonical["generated_at"] == "2026-08-19T06:12:13.456789Z" + assert canonical["evidence_version"] == 1 + assert len(packet.sha256_digest()) == 64 + assert packet.sha256_digest() == build_valid(valid_packet_kwargs).sha256_digest() + + +def test_canonical_evidence_changes_with_governed_artifact_or_version(valid_packet_kwargs: dict[str, object]) -> None: + """Changing exact reviewed evidence or its version changes the immutable packet digest.""" + packet = build_valid(valid_packet_kwargs) + changed_plan = replace(packet, proposed_compensation_plan_digest="2" * 64) + changed_policy = replace(packet, compensation_policy_digest="3" * 64) + changed_version = replace(packet, evidence_version=2) + + assert len({packet.sha256_digest(), changed_plan.sha256_digest(), changed_policy.sha256_digest(), changed_version.sha256_digest()}) == 4 + + +def test_repr_redacts_personal_and_compensation_correlations(valid_packet_kwargs: dict[str, object]) -> None: + """Normal repr formatting cannot leak worker, actor, or evidence correlation identifiers.""" + packet = build_valid(valid_packet_kwargs) + rendered = repr(packet) + + assert rendered == "CompensationChangeReviewPacket()" + for sensitive in ( + packet.tenant_record_id, + packet.person_record_reference, + packet.employment_record_reference, + packet.current_compensation_snapshot_reference, + packet.proposed_compensation_plan_reference, + packet.requester_reference, + packet.current_compensation_snapshot_digest, + ): + assert sensitive not in rendered + + +def test_next_action_requires_identity_scope_and_evidence_before_approval(valid_packet_kwargs: dict[str, object]) -> None: + """The canonical operator instruction orders tenant, actor, worker, and evidence checks first.""" + action = build_valid(valid_packet_kwargs).next_action + actor_clause = "verify their resolved actor identities are distinct" + worker_clause = "prove the Person-to-Employment binding" + evidence_clause = "verify the current compensation snapshot" + approval_clause = "record accountable human approval" + + assert action.startswith("Re-resolve every packet reference within tenant_record_id;") + assert action.index(actor_clause) < action.index(worker_clause) < action.index(evidence_clause) < action.index(approval_clause) + assert "without copying compensation or protected-attribute values" in action + assert action.endswith("execute payroll only through its published owner contract.") + + +@pytest.mark.parametrize( + ("field_name", "value", "message"), + [ + ("contains_personal_data", False, "acknowledge sensitive personal-data correlation"), + ("contains_compensation_values", True, "must not contain compensation values"), + ("contains_protected_attribute_values", True, "must not contain protected-attribute values"), + ("contains_free_form_case_narrative", True, "must not contain free-form case narrative"), + ("contains_free_form_model_output", True, "must not contain free-form model output"), + ("human_confirmation_required", False, "human confirmation is mandatory"), + ("decision_authority", "model_authority", "must remain human_review_only"), + ("review_state", "approved", "must remain requires_human_review"), + ("scope_verification_state", "resolved", "must remain requires_authoritative_resolution"), + ("mutation_state", "authorized", "must remain not_authorized_to_apply"), + ("external_execution_state", "executed", "must remain not_authorized_to_execute"), + ("next_action", "Send directly to payroll.", "next_action must remain"), + ], +) +def test_direct_constructor_and_replace_fail_closed( + field_name: str, + value: object, + message: str, + valid_packet_kwargs: dict[str, object], +) -> None: + """Both construction paths reject attempts to weaken immutable high-impact guardrails.""" + direct_kwargs = valid_packet_kwargs.copy() + direct_kwargs[field_name] = value + with pytest.raises(ValueError, match=message): + CompensationChangeReviewPacket(**direct_kwargs) + + packet = build_valid(valid_packet_kwargs) + with pytest.raises(ValueError, match=message): + replace(packet, **{field_name: value}) + + +@pytest.mark.parametrize( + ("field_name", "value", "message"), + [ + ("tenant_record_id", "not-a-uuid", "canonical UUID text"), + ("tenant_record_id", 42, "canonical UUID text"), + ("tenant_record_id", "00000000-0000-0000-0000-000000000000", "canonical operational UUID"), + ("tenant_record_id", "ffffffff-ffff-ffff-ffff-ffffffffffff", "canonical operational UUID"), + ("tenant_record_id", "11111111-1111-4111-8111-11111111111A", "canonical operational UUID"), + ("person_record_reference", "person_record:seongho", "opaque person_record"), + ("person_record_reference", 42, "opaque person_record"), + ("person_record_reference", "actor:33333333-3333-4333-8333-333333333333", "opaque person_record"), + ("person_record_reference", "person_record:00000000-0000-0000-0000-000000000000", "opaque person_record"), + ("person_record_reference", "person_record:ffffffff-ffff-ffff-ffff-ffffffffffff", "opaque person_record"), + ("person_record_reference", "person_record:33333333-3333-4333-8333-33333333333A", "opaque person_record"), + ("person_record_reference", "person_record:" + "x" * 200, "opaque person_record"), + ("current_compensation_snapshot_digest", "A" * 64, "lowercase SHA-256"), + ("current_compensation_snapshot_digest", "a" * 63, "lowercase SHA-256"), + ("current_compensation_snapshot_digest", 64, "lowercase SHA-256"), + ("purpose_code", "general_hr", "purpose_code must remain"), + ("reason_code", "medical_leave_adjustment", "approved non-sensitive compensation-review category"), + ("proposed_effective_on", "2026-10-01", "must be a date"), + ("proposed_effective_on", datetime(2026, 10, 1, tzinfo=timezone.utc), "must be a date"), + ("generated_at", datetime(2026, 8, 19, 6, 12), "generated_at must be timezone-aware"), + ("generated_at", "2026-08-19T06:12:00Z", "generated_at must be timezone-aware"), + ("evidence_version", True, "evidence_version must be an integer"), + ("evidence_version", 0, "evidence_version must be an integer"), + ("evidence_version", 2_147_483_648, "evidence_version must be an integer"), + ], +) +def test_invalid_core_inputs_fail_closed( + field_name: str, + value: object, + message: str, + valid_packet_kwargs: dict[str, object], +) -> None: + """Malformed identity, evidence, purpose, time, and version inputs are rejected.""" + kwargs = valid_packet_kwargs.copy() + kwargs[field_name] = value + with pytest.raises(ValueError, match=message): + build_compensation_change_review_packet(**kwargs) + + +def test_every_reference_and_digest_is_validated(valid_packet_kwargs: dict[str, object]) -> None: + """Each trust-bearing reference and digest field is individually bound to its namespace.""" + packet = build_valid(valid_packet_kwargs) + reference_fields = { + "compensation_review_reference": "compensation_change_review", + "person_record_reference": "person_record", + "employment_record_reference": "employment_record", + "active_assignment_snapshot_reference": "active_assignment_snapshot", + "current_compensation_snapshot_reference": "compensation_snapshot", + "proposed_compensation_plan_reference": "compensation_plan", + "compensation_policy_reference": "compensation_policy", + "pay_equity_review_reference": "pay_equity_review", + "budget_authorization_reference": "budget_authorization", + "payroll_handoff_plan_reference": "payroll_handoff_plan", + "requester_reference": "actor", + "reviewer_reference": "actor", + } + digest_fields = [name for name in valid_packet_kwargs if name.endswith("_digest")] + + for field_name, prefix in reference_fields.items(): + with pytest.raises(ValueError, match=re.escape(f"opaque {prefix}: reference")): + replace(packet, **{field_name: "wrong:11111111-1111-4111-8111-111111111111"}) + for field_name in digest_fields: + with pytest.raises(ValueError, match="lowercase SHA-256"): + replace(packet, **{field_name: "z" * 64}) + + +def test_same_opaque_actor_reference_is_rejected_early(valid_packet_kwargs: dict[str, object]) -> None: + """Exact requester/reviewer reuse is denied before authoritative identity re-resolution.""" + kwargs = valid_packet_kwargs.copy() + kwargs["reviewer_reference"] = kwargs["requester_reference"] + with pytest.raises(ValueError, match="different actor references"): + build_compensation_change_review_packet(**kwargs) + + +def test_fractional_and_offset_timestamps_preserve_distinct_instants(valid_packet_kwargs: dict[str, object]) -> None: + """Canonical evidence preserves microseconds while normalizing equivalent offsets to UTC.""" + packet = build_valid(valid_packet_kwargs) + later = replace(packet, generated_at=packet.generated_at.replace(microsecond=456790)) + offset = replace( + packet, + generated_at=datetime(2026, 8, 19, 15, 12, 13, 456789, tzinfo=timezone(timedelta(hours=9))), + ) + + assert packet.sha256_digest() != later.sha256_digest() + assert packet.canonical_json() == offset.canonical_json() + + +class UnknownOffset(tzinfo): + """Timezone fixture whose UTC offset is intentionally unknown.""" + + def utcoffset(self, dt: datetime | None) -> None: + """Return no offset so the packet must reject the timestamp.""" + return None + + def dst(self, dt: datetime | None) -> None: + """Return no daylight-saving offset for the invalid fixture.""" + return None + + +def test_timezone_with_unknown_offset_is_rejected(valid_packet_kwargs: dict[str, object]) -> None: + """A tzinfo object is insufficient when it cannot establish an actual UTC offset.""" + kwargs = valid_packet_kwargs.copy() + kwargs["generated_at"] = datetime(2026, 8, 19, 6, 12, tzinfo=UnknownOffset()) + with pytest.raises(ValueError, match="generated_at must be timezone-aware"): + build_compensation_change_review_packet(**kwargs) diff --git a/packages/compensation-change-review/tests/test_post_issuance_evidence_integrity.py b/packages/compensation-change-review/tests/test_post_issuance_evidence_integrity.py new file mode 100644 index 000000000..fde9adc60 --- /dev/null +++ b/packages/compensation-change-review/tests/test_post_issuance_evidence_integrity.py @@ -0,0 +1,52 @@ +"""Regression coverage for post-issuance compensation-review evidence integrity.""" + +from copy import copy +from gc import collect + +import pytest + +from orgmetra_compensation_change_review import ( + CompensationChangeReviewPacket, + build_compensation_change_review_packet, +) + + +def _packet(valid_packet_kwargs: dict[str, object]) -> CompensationChangeReviewPacket: + """Build one governed packet through the supported public constructor.""" + return build_compensation_change_review_packet(**valid_packet_kwargs) + + +def test_valid_value_mutation_cannot_rewrite_emitted_evidence(valid_packet_kwargs: dict[str, object]) -> None: + """Low-level replacement with another valid value cannot emit a second audit truth.""" + packet = _packet(valid_packet_kwargs) + original = packet.canonical_json() + object.__setattr__( + packet, + "compensation_policy_digest", + "2" * 64, + ) + + with pytest.raises(ValueError, match="integrity"): + packet.canonical_json() + assert '"compensation_policy_digest":"' + ("d" * 64) + '"' in original + + +def test_shallow_copy_does_not_inherit_process_local_issuance_evidence(valid_packet_kwargs: dict[str, object]) -> None: + """Unsupported object copies must fail closed rather than inheriting issuance trust.""" + packet = _packet(valid_packet_kwargs) + copied = copy(packet) + + assert copied is not packet + with pytest.raises(ValueError, match="integrity"): + copied.canonical_json() + + +def test_collected_packet_releases_process_local_issuance_binding(valid_packet_kwargs: dict[str, object]) -> None: + """Weak cleanup must not leave stale process-local issuance state behind.""" + packet = _packet(valid_packet_kwargs) + assert packet.canonical_json() + del packet + collect() + + replacement = _packet(valid_packet_kwargs) + assert replacement.canonical_json() diff --git a/packages/compensation-change-review/tests/test_quality_workflow_trigger.py b/packages/compensation-change-review/tests/test_quality_workflow_trigger.py new file mode 100644 index 000000000..b6e53ca18 --- /dev/null +++ b/packages/compensation-change-review/tests/test_quality_workflow_trigger.py @@ -0,0 +1,27 @@ +"""Regression tests for consolidated compensation-review quality admission.""" + +from pathlib import Path + + +_FOUNDATION_WORKFLOW = Path(".github/workflows/foundation-ci.yml") +_RETIRED_LEAF_WORKFLOW = Path( + ".github/workflows/compensation-change-review-quality.yml" +) +_PACKAGE_TEST_COMMAND = ( + "PYTHONPATH=packages/compensation-change-review/src " + "COVERAGE_FILE=/tmp/orgmetra-compensation-change-review.coverage " + "python -m pytest -c packages/compensation-change-review/pyproject.toml " + "packages/compensation-change-review/tests" +) + + +def test_foundation_runs_compensation_review_with_exact_package_coverage() -> None: + """Keep compensation review inside the canonical one-job Foundation lane.""" + workflow = _FOUNDATION_WORKFLOW.read_text(encoding="utf-8") + + assert _PACKAGE_TEST_COMMAND in workflow + + +def test_leaf_quality_workflow_stays_retired() -> None: + """Do not recreate a package-specific runner after repository consolidation.""" + assert not _RETIRED_LEAF_WORKFLOW.exists() diff --git a/packages/compensation-change-review/tests/test_reference_privacy.py b/packages/compensation-change-review/tests/test_reference_privacy.py new file mode 100644 index 000000000..c419f3078 --- /dev/null +++ b/packages/compensation-change-review/tests/test_reference_privacy.py @@ -0,0 +1,42 @@ +"""Privacy regressions for opaque compensation-review trust references.""" +from dataclasses import replace + +import pytest + +from orgmetra_compensation_change_review import build_compensation_change_review_packet + +UUID1_ID = "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + + +@pytest.mark.parametrize( + ("field_name", "prefix"), + [ + ("compensation_review_reference", "compensation_change_review"), + ("person_record_reference", "person_record"), + ("employment_record_reference", "employment_record"), + ("active_assignment_snapshot_reference", "active_assignment_snapshot"), + ("current_compensation_snapshot_reference", "compensation_snapshot"), + ("proposed_compensation_plan_reference", "compensation_plan"), + ("compensation_policy_reference", "compensation_policy"), + ("pay_equity_review_reference", "pay_equity_review"), + ("budget_authorization_reference", "budget_authorization"), + ("payroll_handoff_plan_reference", "payroll_handoff_plan"), + ("requester_reference", "actor"), + ("reviewer_reference", "actor"), + ], +) +def test_uuid1_trust_reference_is_rejected_by_builder_and_replace( + field_name: str, + prefix: str, + valid_packet_kwargs: dict[str, object], +) -> None: + """UUIDv1 timestamp/node metadata must never enter an opaque trust-reference field.""" + value = f"{prefix}:{UUID1_ID}" + kwargs = valid_packet_kwargs.copy() + kwargs[field_name] = value + with pytest.raises(ValueError, match=field_name): + build_compensation_change_review_packet(**kwargs) + + packet = build_compensation_change_review_packet(**valid_packet_kwargs) + with pytest.raises(ValueError, match=field_name): + replace(packet, **{field_name: value}) diff --git a/packages/compensation-change-review/tests/test_shared_fixture_architecture.py b/packages/compensation-change-review/tests/test_shared_fixture_architecture.py new file mode 100644 index 000000000..48cab100c --- /dev/null +++ b/packages/compensation-change-review/tests/test_shared_fixture_architecture.py @@ -0,0 +1,19 @@ +"""Regression coverage for one canonical compensation-review test input fixture.""" + +from pathlib import Path + + +def test_valid_packet_input_fixture_has_one_canonical_definition() -> None: + """Keep the 24-field valid packet seed in conftest instead of copy-pasted tests.""" + tests_dir = Path(__file__).parent + marker = ( + '"compensation_review_reference": ' + '"compensation_change_review:22222222-2222-4222-8222-222222222222"' + ) + owners = sorted( + path.name + for path in tests_dir.glob("*.py") + if marker in path.read_text(encoding="utf-8") + ) + + assert owners == ["conftest.py"] diff --git a/packages/compensation-change-review/tests/test_string_runtime_evidence_integrity.py b/packages/compensation-change-review/tests/test_string_runtime_evidence_integrity.py new file mode 100644 index 000000000..914969800 --- /dev/null +++ b/packages/compensation-change-review/tests/test_string_runtime_evidence_integrity.py @@ -0,0 +1,104 @@ +"""Regression coverage for string-subclass evidence-boundary integrity.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from orgmetra_compensation_change_review import build_compensation_change_review_packet + + +class ForgedReference(str): + """String subclass that forges namespace and UUID suffix validation.""" + + def startswith(self, prefix, *args): # type: ignore[no-untyped-def] + return True + + def split(self, sep=None, maxsplit=-1): # type: ignore[no-untyped-def] + return ["evil", "22222222-2222-4222-8222-222222222222"] + + +class ForgedTenantUUIDText(str): + """String subclass that forges UUID parsing and canonical-equality checks.""" + + def replace(self, old, new, *args): # type: ignore[no-untyped-def] + canonical = "11111111-1111-4111-8111-111111111111" + return canonical.replace(old, new, *args) + + def __eq__(self, other): # type: ignore[no-untyped-def] + return other is not None + + def __ne__(self, other): # type: ignore[no-untyped-def] + return other is None + + +class ForgedGovernanceText(str): + """String subclass that forges fixed equality and allow-list membership.""" + + def __eq__(self, other): # type: ignore[no-untyped-def] + return True + + def __ne__(self, other): # type: ignore[no-untyped-def] + return False + + def __hash__(self) -> int: + return hash("annual_compensation_review") + + +def test_rejects_reference_string_subclass_that_can_forge_namespace_validation( + valid_packet_kwargs: dict[str, object], +) -> None: + kwargs = valid_packet_kwargs.copy() + kwargs["compensation_review_reference"] = ForgedReference("evil:payload") + with pytest.raises(ValueError, match="compensation_review_reference"): + build_compensation_change_review_packet(**kwargs) + + +def test_rejects_tenant_string_subclass_that_can_forge_uuid_validation( + valid_packet_kwargs: dict[str, object], +) -> None: + kwargs = valid_packet_kwargs.copy() + kwargs["tenant_record_id"] = ForgedTenantUUIDText("not-a-tenant-uuid") + with pytest.raises(ValueError, match="tenant_record_id"): + build_compensation_change_review_packet(**kwargs) + + +def test_rejects_forged_purpose_and_reason_codes(valid_packet_kwargs: dict[str, object]) -> None: + purpose_kwargs = valid_packet_kwargs.copy() + purpose_kwargs["purpose_code"] = ForgedGovernanceText("attacker_controlled_purpose") + with pytest.raises(ValueError, match="purpose_code"): + build_compensation_change_review_packet(**purpose_kwargs) + + reason_kwargs = valid_packet_kwargs.copy() + reason_kwargs["reason_code"] = ForgedGovernanceText("attacker_controlled_reason") + with pytest.raises(ValueError, match="reason_code"): + build_compensation_change_review_packet(**reason_kwargs) + + +def test_rejects_digest_string_subclass_before_pattern_match(valid_packet_kwargs: dict[str, object]) -> None: + """Digest evidence must not retain caller-defined string behavior.""" + kwargs = valid_packet_kwargs.copy() + kwargs["compensation_policy_digest"] = ForgedGovernanceText("a" * 64) + with pytest.raises(ValueError, match="compensation_policy_digest"): + build_compensation_change_review_packet(**kwargs) + + +@pytest.mark.parametrize( + "field_name", + ( + "decision_authority", + "review_state", + "scope_verification_state", + "mutation_state", + "external_execution_state", + "next_action", + ), +) +def test_rejects_forged_direct_construction_constant_text( + field_name: str, + valid_packet_kwargs: dict[str, object], +) -> None: + packet = build_compensation_change_review_packet(**valid_packet_kwargs) + with pytest.raises(ValueError, match=field_name): + replace(packet, **{field_name: ForgedGovernanceText("attacker_controlled_text")}) diff --git a/packages/compensation-change-review/tests/test_temporal_evidence_integrity.py b/packages/compensation-change-review/tests/test_temporal_evidence_integrity.py new file mode 100644 index 000000000..f2087cc13 --- /dev/null +++ b/packages/compensation-change-review/tests/test_temporal_evidence_integrity.py @@ -0,0 +1,106 @@ +"""Regression coverage for compensation-review recorded-time evidence integrity.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone, tzinfo + +import pytest + +from orgmetra_compensation_change_review import build_compensation_change_review_packet + + +class ForgedDateTime(datetime): + """Datetime subclass able to forge canonical recorded-time evidence.""" + + def astimezone(self, tz=None): # type: ignore[no-untyped-def] + """Keep the hostile subclass alive across UTC normalization.""" + return self + + def isoformat(self, *args, **kwargs) -> str: # type: ignore[no-untyped-def] + """Return an instant different from the underlying review evidence.""" + return "2099-12-31T23:59:59+00:00" + + +class MutableOffsetTimezone(tzinfo): + """Timezone whose offset can change after a packet has been issued.""" + + def __init__(self, offset_hours: int) -> None: + self.offset_hours = offset_hours + + def utcoffset(self, dt): # type: ignore[no-untyped-def] + """Return the current mutable offset.""" + return timedelta(hours=self.offset_hours) + + def dst(self, dt): # type: ignore[no-untyped-def] + """Keep daylight-saving behavior deterministic for the regression.""" + return timedelta(0) + + +class MissingOffsetTimezone(tzinfo): + """Timezone object that cannot establish a concrete UTC offset.""" + + def utcoffset(self, dt): # type: ignore[no-untyped-def] + """Signal that the recorded instant cannot be resolved.""" + return None + + def dst(self, dt): # type: ignore[no-untyped-def] + """Keep daylight-saving behavior absent for this invalid timezone.""" + return None + + +def test_rejects_datetime_subclasses_that_can_forge_recorded_time_evidence( + valid_packet_kwargs: dict[str, object], +) -> None: + """Canonical audit evidence must not call caller-overridable datetime methods.""" + kwargs = valid_packet_kwargs.copy() + kwargs["generated_at"] = ForgedDateTime(2026, 8, 21, 4, 25, tzinfo=timezone.utc) + + with pytest.raises(ValueError, match="generated_at"): + build_compensation_change_review_packet(**kwargs) + + +def test_rejects_timezone_without_resolvable_utc_offset( + valid_packet_kwargs: dict[str, object], +) -> None: + """Recorded-time evidence must fail closed when its UTC instant is indeterminate.""" + kwargs = valid_packet_kwargs.copy() + kwargs["generated_at"] = datetime(2026, 8, 21, 4, 25, tzinfo=MissingOffsetTimezone()) + + with pytest.raises(ValueError, match="generated_at"): + build_compensation_change_review_packet(**kwargs) + + +def test_corrupted_post_issuance_time_fails_closed_before_canonical_export( + valid_packet_kwargs: dict[str, object], +) -> None: + """Low-level mutation must not make an indeterminate recorded time exportable.""" + packet = build_compensation_change_review_packet(**valid_packet_kwargs) + object.__setattr__( + packet, + "generated_at", + datetime(2026, 8, 21, 4, 25, tzinfo=MissingOffsetTimezone()), + ) + + with pytest.raises(ValueError, match="generated_at"): + packet.canonical_json() + + +def test_freezes_mutable_timezone_before_issuing_recorded_time_evidence( + valid_packet_kwargs: dict[str, object], +) -> None: + """Caller-owned tzinfo mutation must not rewrite or invalidate issued audit evidence.""" + mutable_timezone = MutableOffsetTimezone(9) + kwargs = valid_packet_kwargs.copy() + kwargs["generated_at"] = datetime(2026, 8, 21, 13, 25, tzinfo=mutable_timezone) + + packet = build_compensation_change_review_packet(**kwargs) + canonical_before = packet.canonical_json() + digest_before = packet.sha256_digest() + + assert packet.generated_at.tzinfo is timezone.utc + assert '"generated_at":"2026-08-21T04:25:00Z"' in canonical_before + + mutable_timezone.offset_hours = -5 + + assert packet.canonical_json() == canonical_before + assert packet.sha256_digest() == digest_before diff --git a/packages/compensation-change-review/tests/test_tenant_uuid_privacy.py b/packages/compensation-change-review/tests/test_tenant_uuid_privacy.py new file mode 100644 index 000000000..a5480a594 --- /dev/null +++ b/packages/compensation-change-review/tests/test_tenant_uuid_privacy.py @@ -0,0 +1,39 @@ +"""Tenant identity interoperability regressions for compensation-change review evidence.""" + +import pytest + +from orgmetra_compensation_change_review import build_compensation_change_review_packet + + +_AUTHORITATIVE_UUIDV7_TENANT = "10000000-0000-7000-8000-000000000001" + + +def _build_with_tenant(tenant_record_id: str, valid_packet_kwargs: dict[str, object]): + """Build a valid value-minimized compensation packet around one tenant identity.""" + kwargs = valid_packet_kwargs.copy() + kwargs["tenant_record_id"] = tenant_record_id + return build_compensation_change_review_packet(**kwargs) + + +def test_accepts_authoritative_operational_uuidv7_tenant_identity( + valid_packet_kwargs: dict[str, object], +) -> None: + """Accept the canonical UUIDv7 tenant form already accepted by protected HRIS core.""" + packet = _build_with_tenant(_AUTHORITATIVE_UUIDV7_TENANT, valid_packet_kwargs) + assert packet.tenant_record_id == _AUTHORITATIVE_UUIDV7_TENANT + + +@pytest.mark.parametrize( + "tenant_record_id", + [ + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + ], +) +def test_rejects_reserved_sentinel_tenant_identity( + tenant_record_id: str, + valid_packet_kwargs: dict[str, object], +) -> None: + """Reject RFC 9562 Nil/Max sentinels while deferring UUID version policy to HRIS core.""" + with pytest.raises(ValueError, match="tenant_record_id"): + _build_with_tenant(tenant_record_id, valid_packet_kwargs)