diff --git a/.github/workflows/foundation-ci.yml b/.github/workflows/foundation-ci.yml index 6b475d6f2..8bfd78a92 100644 --- a/.github/workflows/foundation-ci.yml +++ b/.github/workflows/foundation-ci.yml @@ -52,10 +52,33 @@ jobs: run: npm run validate - name: Prove Foundation CI dependency hygiene run: bash tests/test_foundation_ci_dependency_hygiene.sh + - name: Set up exact Position Reporting Change Review Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14.7" + check-latest: false - name: Install reviewed test toolchain run: | python -m pip install --require-hashes --no-deps --only-binary=:all: -r .github/requirements/foundation-test.txt python -m pip check + - name: Run Position reporting change review installed-artifact contract + run: | + printf '%s\n' 'setuptools==84.0.0 --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670' > /tmp/orgmetra-position-reporting-change-review-build.txt + python -m pip install --require-hashes --no-deps --only-binary=:all: -r /tmp/orgmetra-position-reporting-change-review-build.txt + rm -rf /tmp/orgmetra-position-reporting-change-review-build /tmp/orgmetra-position-reporting-change-review-dist /tmp/orgmetra-position-reporting-change-review-venv + cp -a packages/position-reporting-change-review /tmp/orgmetra-position-reporting-change-review-build + mkdir -p /tmp/orgmetra-position-reporting-change-review-dist + python -m pip wheel --no-deps --no-build-isolation --wheel-dir /tmp/orgmetra-position-reporting-change-review-dist /tmp/orgmetra-position-reporting-change-review-build + test "$(find /tmp/orgmetra-position-reporting-change-review-dist -maxdepth 1 -type f -name '*.whl' | wc -l)" -eq 1 + python -m venv /tmp/orgmetra-position-reporting-change-review-venv + /tmp/orgmetra-position-reporting-change-review-venv/bin/python -m pip install --require-hashes --no-deps --only-binary=:all: -r "$GITHUB_WORKSPACE/.github/requirements/foundation-test.txt" + wheel_path="$(find /tmp/orgmetra-position-reporting-change-review-dist -maxdepth 1 -type f -name '*.whl' -print -quit)" + wheel_sha="$(sha256sum "$wheel_path" | awk '{print $1}')" + printf 'orgmetra-position-reporting-change-review[test] @ file://%s --hash=sha256:%s\n' "$wheel_path" "$wheel_sha" > /tmp/orgmetra-position-reporting-change-review-install.txt + /tmp/orgmetra-position-reporting-change-review-venv/bin/python -m pip install --require-hashes --no-deps -r /tmp/orgmetra-position-reporting-change-review-install.txt + /tmp/orgmetra-position-reporting-change-review-venv/bin/python -m pip check + cd /tmp + COVERAGE_FILE=/tmp/orgmetra-position-reporting-change-review.coverage /tmp/orgmetra-position-reporting-change-review-venv/bin/python -m pytest -c "$GITHUB_WORKSPACE/packages/position-reporting-change-review/pyproject.toml" "$GITHUB_WORKSPACE/packages/position-reporting-change-review/tests" - 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 diff --git a/docs/adr/0095-governed-position-reporting-change-review.md b/docs/adr/0095-governed-position-reporting-change-review.md new file mode 100644 index 000000000..cc081a1e8 --- /dev/null +++ b/docs/adr/0095-governed-position-reporting-change-review.md @@ -0,0 +1,37 @@ +# ADR 0095: Governed Position reporting-change review before authoritative mutation + +- **Status:** Proposed — active PR truth only +- **Date:** 2026-08-23 + +## Context + +Orgmetra already separates Job, Position and Assignment and models effective/business time separately from system-recorded time. Buyers also need defensible organizational reporting-line change controls: a manager-position reassignment can alter authority, span of control, approvals and workforce reporting even when no Person record is changed. + +Protected `develop` does not yet ship an authoritative Position-reporting mutation boundary. Active PR #94 adds a read-only bitemporal Position-to-Position hierarchy, but active-PR behavior cannot be treated as protected-main truth or imported as an undeclared branch dependency. A review artifact can still be valuable now if it remains transport-neutral, fail-closed and explicitly unable to mutate HRIS data. + +NIST SP 800-53 Rev. 5 Release 5.2.0 retains AC-5 separation-of-duties principles and AU-3 audit-record content/minimization considerations. NIST Privacy Framework 1.0 is the current final Privacy Framework while 1.1 remains non-final; its risk-based minimization framing supports keeping Person/worker values outside this organizational change envelope. RFC 9562 defines UUIDv4 and UUIDv7 and supports preserving the HRIS owner's operational UUID evolution while using UUIDv4 for leaf-owned random correlation references. These sources inform design controls; they are not certification or legal-compliance claims. + +## Decision + +Add `PositionReportingChangeReviewPacket` as a bounded pre-mutation evidence contract that: + +1. binds one authoritative tenant plus subordinate, current-manager and proposed-manager Position references without copying Person identity or worker values; +2. binds `effective_on` as business time and `recorded_at` as a separately canonicalized system-recorded evidence instant; +3. binds exact Position-scope and organization-scope SHA-256 evidence plus a controlled reporting-change reason and explicit evidence version; +4. requires requester/reviewer reference inequality as an early guard but still requires authoritative actor resolution before mutation; +5. rejects subordinate=current manager, subordinate=proposed manager and current=proposed manager to prevent self-reporting and no-op evidence; +6. keeps `review_state=requires_human_review`, `scope_verification_state=requires_authoritative_resolution`, `mutation_state=not_authorized_to_apply`, and `decision_authority=human_review_only` immutable; +7. requires the host, immediately before mutation, to re-resolve all three Position records and the current solid-line relationship in the exact tenant and bitemporal coordinate, prove Position validity/staffability, authoritative reviewer separation, no cycle and no multiple visible solid-line managers, and then produce immutable audit/outbox evidence; and +8. performs no database mutation, no cross-service application-table SQL, no identity-provider write and no autonomous employment decision. + +Operational HRIS-owned UUIDs remain canonical non-sentinel UUIDs rather than being narrowed to UUIDv4. Packet-owned change/actor correlation references require canonical UUIDv4. The package uses a process-local creation seal only to detect in-process post-construction evidence mutation; durable reference uniqueness and durable immutability remain responsibilities of authoritative persistence/audit boundaries. + +## Consequences + +A buyer can inspect and hash a minimally identifying, human-review-only reporting reassignment before any organizational truth changes. The packet explicitly separates review evidence from mutation authority and makes the next authoritative checks visible instead of implying that identifier syntax proves organizational validity. + +The deliberate limitation is that the packet cannot establish that the current reporting relationship exists, that the proposed manager is valid at the requested date, that the change is policy/legal compliant, or that persistence succeeded. Those claims require authoritative runtime evidence after the relevant HRIS capability is integrated. + +## References + +See `docs/doctoring/position-reporting-change-review-references.md`. diff --git a/docs/doctoring/position-reporting-change-review-references.md b/docs/doctoring/position-reporting-change-review-references.md new file mode 100644 index 000000000..d5cc97695 --- /dev/null +++ b/docs/doctoring/position-reporting-change-review-references.md @@ -0,0 +1,18 @@ +# Position reporting-change review references + +Verified against official primary-source pages on 2026-08-23. These references inform the governance/evidence boundary only. Orgmetra does not claim NIST certification, universal legal compliance, or conformance merely because this package uses these design principles. + +## APA 7 + +Boeckl, K., & Lefkovitz, N. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management, Version 1.0* (NIST CSWP 10). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.CSWP.10 + +Davis, K., Peabody, B., & Leach, P. (2024). *Universally unique IDentifiers (UUIDs)* (RFC 9562). RFC Editor. https://doi.org/10.17487/RFC9562 + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5; Release 5.2.0 supplemental control catalog current as of August 2025). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +## Applied boundary + +- NIST SP 800-53 Rev. 5 AC-5 supports separation of duties. Orgmetra therefore treats requester/reviewer string inequality only as an early syntactic guard and requires authoritative actor resolution before reporting-line mutation. +- NIST SP 800-53 Rev. 5 AU-3 requires useful audit context while explicitly recognizing privacy risk in audit records. The packet therefore records organizational correlation, outcome state, reason category and timing while excluding Person identity, compensation, ratings and free-form worker narratives. +- NIST Privacy Framework 1.0 is the final framework used here for privacy-risk/minimization framing. NIST's public site currently presents Version 1.1 as non-final work; this ADR does not treat 1.1 as a final standard. +- RFC 9562 defines both UUIDv4 and UUIDv7. Orgmetra preserves the authoritative HRIS operational UUID contract, including UUIDv7, while leaf-owned change and actor correlation references use UUIDv4 to avoid silently redefining core identifier ownership. diff --git a/docs/traceability/position-reporting-change-review.md b/docs/traceability/position-reporting-change-review.md new file mode 100644 index 000000000..1fb8aba07 --- /dev/null +++ b/docs/traceability/position-reporting-change-review.md @@ -0,0 +1,28 @@ +# Position reporting-change review traceability + +**Status:** active PR / proposed capability. Not protected-main truth until merged from one fully validated exact head. + +## Current protected-parent adoption + +Feature predecessor `adf055d79d188ba18d06ecf80dc1117858c987f4` was non-destructively reconciled with protected `develop@eb9757f8649aaad026a9865508d9aad50c1a7a4f` by ordinary two-parent adoption commit `2317c367aa200e2cd1636cc26dd96c8b01966804`. The resulting tree preserves the protected #161 repository-workflow consolidation and does not import mutable #94 source. Historical checks on the predecessor remain causal evidence only; the resulting successor must reacquire all applicable exact-head gates before Ready or merge. + +Fresh Foundation execution on successor `c0975db9f976fda93696591ac04a9787cde4aef5` exposed a repository-workflow reconciliation RED before package tests: the feature tree had resurrected its package-local quality workflow with `runs-on: ubuntu-latest`, violating protected #161's exact `ubuntu-24.04` runner and two-local-workflow inventory contract. The repair keeps the leaf retired and moves its exact installed-wheel, hash-bound installation, pinned CPython 3.14.7 and 100% coverage contract into canonical one-job Foundation CI. + +| Requirement | Design / implementation evidence | Executable evidence | +|---|---|---| +| Keep reporting authority attached to Position rather than Person | Packet carries subordinate/current-manager/proposed-manager `position_record:` references and no Person identifier | `test_builds_value_minimized_human_review_packet`, `test_operational_position_references_accept_uuid7` | +| Preserve authoritative identifier ownership | Tenant and Position references accept canonical non-sentinel operational UUIDs; leaf-owned change and actor references require canonical UUIDv4 | invalid trust-evidence matrix plus UUIDv7 interoperability regression | +| Keep review separate from mutation authority | Fixed `mutation_state=not_authorized_to_apply`, `decision_authority=human_review_only`, mandatory human review | `test_direct_construction_cannot_weaken_governance` | +| Require authoritative bitemporal scope before mutation | Fixed `requires_authoritative_resolution`; next action requires exact tenant, `effective_on`, current system-recorded cutoff, Position validity/staffability and current relationship resolution | `test_next_action_preserves_authoritative_bitemporal_and_audit_boundary` | +| Prevent obvious invalid reporting proposals | subordinate/current/proposed Position references must be pairwise different | `test_rejects_ambiguous_reporting_or_actor_relationships` | +| Require authoritative cycle/cardinality checks | next action explicitly rejects cycles and multiple visible solid-line managers before mutation | `test_next_action_preserves_authoritative_bitemporal_and_audit_boundary` | +| Separate requester and reviewer | identical actor references are rejected locally; next action still requires authoritative identity separation | ambiguous relationship regression and next-action regression | +| Minimize durable privacy surface | no Person identifier, worker value, compensation, rating or free-form reason; controlled reason vocabulary only | value-minimization regression and invalid reason matrix | +| Preserve business time vs system-recorded time | exact `effective_on` date is distinct from exact fixed-offset `recorded_at`, canonicalized to UTC | fixed-offset and noncanonical temporal regressions | +| Bind reviewed organizational evidence | Position-scope and organization-scope snapshots require lowercase SHA-256 digests | invalid digest matrix and deterministic canonical-evidence regression | +| Prevent checked-vs-emitted runtime forgery | exact built-in text/int/date/datetime primitives and creation-time canonical seal | hostile runtime-subclass regressions and post-construction tamper regression | +| Require immutable audit/outbox before mutation | next action routes only after authoritative resolution and requires immutable audit/outbox evidence | next-action regression | +| Exact 100% owned statement/branch coverage and installed artifact truth | canonical Foundation builds one exact wheel, hash-binds installation, uses pinned CPython 3.14.7 and executes the package suite under its unchanged 100% statement/branch threshold | `.github/workflows/foundation-ci.yml`; `test_repository_contract.py` | +| Governance-only edits still enter required validation | canonical Foundation triggers for every pull request to `develop`; the package regression keeps the retired leaf from returning and pins the installed-artifact step | `test_canonical_foundation_executes_installed_artifact_contract` | + +The packet deliberately cannot prove that the current reporting edge exists, that a proposed manager is authorized or legally permissible, or that persistence succeeded. Those remain authoritative HRIS/runtime/policy evidence and must not be inferred from a packet digest. diff --git a/manifest.json b/manifest.json index f7b6cf55e..cb803d447 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": "81ae584c9c3dd89d7e86011550d9afec439f17b46a1b21db2fc8104f9149aab3", + "bytes": 9199, + "lines": 148 }, { "path": ".gitignore", diff --git a/packages/position-reporting-change-review/CHANGELOG.md b/packages/position-reporting-change-review/CHANGELOG.md new file mode 100644 index 000000000..a889fbb73 --- /dev/null +++ b/packages/position-reporting-change-review/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## Unreleased + +### Added + +- Governed pre-mutation review packet for one solid-line Position-to-Position reporting reassignment. +- Separate subordinate, current-manager and proposed-manager Position correlations with self-reporting and no-op change rejection. +- Business-effective date plus separately recorded system evidence time. +- Position-scope and organization-scope SHA-256 evidence binding, controlled reporting-change reasons, requester/reviewer separation and explicit evidence versioning. +- Fail-closed `requires_authoritative_resolution`, `not_authorized_to_apply` and `human_review_only` states. +- PII/worker-value/employment-decision minimization, redacted representation, exact runtime-type validation and post-construction canonical-evidence tamper detection. +- Exact installed-wheel quality execution with pinned CPython 3.14.7 and 100% owned statement/branch coverage. +- Retire the package-specific workflow after protected repository-workflow consolidation and preserve the same installed-artifact contract inside canonical one-job Foundation CI. diff --git a/packages/position-reporting-change-review/README.md b/packages/position-reporting-change-review/README.md new file mode 100644 index 000000000..b168015c4 --- /dev/null +++ b/packages/position-reporting-change-review/README.md @@ -0,0 +1,28 @@ +# Orgmetra Position Reporting Change Review + +`orgmetra-position-reporting-change-review` is a transport-neutral, pre-mutation governance contract for reviewing a change to one solid-line Position-to-Position reporting relationship. + +## Protected-main truth and scope + +Protected `develop` keeps Job, Position and Assignment as separate HRIS concepts but does not yet ship an authoritative reporting-line mutation service. An active Position-reporting hierarchy PR adds read-only bitemporal relationship reconstruction; this package does not import it, depend on its branch, or treat active-PR behavior as protected-main truth. + +The review packet binds an authoritative tenant, subordinate Position, current manager Position, proposed manager Position, requested business-effective date, exact Position/organization scope digests, controlled reason, requester, reviewer, evidence version and system-recorded time. It contains no Person identifier, worker value, compensation, performance rating, free-form reason or LLM decision. + +## What happens next + +A valid packet is **not permission to change HRIS data**. Before any reporting-line mutation, the host must re-resolve the three Position records and the current solid-line relationship in the exact tenant at `effective_on` and the current system-recorded cutoff; prove the Positions are valid and staffable; prove requester/reviewer authoritative identity separation; reject self-reporting, cycles and multiple visible solid-line managers; verify the reviewed evidence digests and reason; and only then invoke the future authoritative mutation boundary with immutable audit/outbox evidence. + +The package performs no database write, no direct cross-service application-table SQL, no identity-provider mutation and no autonomous employment decision. + +## Identifier and evidence rules + +- `tenant_record_id` follows the protected HRIS canonical non-sentinel operational UUID contract, including UUIDv7. +- Position references use `position_record:` so the leaf package does not duplicate a UUIDv4-only rule over HRIS-owned records. +- The packet-owned `position_reporting_change:` reference and `actor:` review correlations require canonical UUIDv4. +- Position and organization scope snapshots are bound by lowercase SHA-256 digests rather than copied values. +- `effective_on` is business time. `recorded_at` is the system-recorded evidence instant and is canonicalized to UTC RFC 3339 text. +- `mutation_state=not_authorized_to_apply`, `scope_verification_state=requires_authoritative_resolution`, and `decision_authority=human_review_only` are fail-closed constants. + +## Quality evidence + +The dedicated workflow builds the exact wheel from the PR head, installs it through a SHA-256-bound requirement, runs the installed artifact on pinned CPython 3.14.7, requires exact 100% owned statement/branch coverage, and leaves the checkout clean. Repository-level tests require ADR, doctoring and traceability-only edits to trigger the same gate. diff --git a/packages/position-reporting-change-review/pyproject.toml b/packages/position-reporting-change-review/pyproject.toml new file mode 100644 index 000000000..69aa813d9 --- /dev/null +++ b/packages/position-reporting-change-review/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=84"] +build-backend = "setuptools.build_meta" + +[project] +name = "orgmetra-position-reporting-change-review" +version = "0.1.0" +description = "Governed Position reporting-change review evidence for Orgmetra." +requires-python = ">=3.14" + +[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_position_reporting_change_review", + "--cov-branch", + "--cov-report=term-missing", + "--cov-fail-under=100", +] diff --git a/packages/position-reporting-change-review/src/orgmetra_position_reporting_change_review/__init__.py b/packages/position-reporting-change-review/src/orgmetra_position_reporting_change_review/__init__.py new file mode 100644 index 000000000..70e714cc0 --- /dev/null +++ b/packages/position-reporting-change-review/src/orgmetra_position_reporting_change_review/__init__.py @@ -0,0 +1,11 @@ +"""Public API for governed Position reporting-change review evidence.""" + +from .review import ( + PositionReportingChangeReviewPacket, + build_position_reporting_change_review_packet, +) + +__all__ = [ + "PositionReportingChangeReviewPacket", + "build_position_reporting_change_review_packet", +] diff --git a/packages/position-reporting-change-review/src/orgmetra_position_reporting_change_review/review.py b/packages/position-reporting-change-review/src/orgmetra_position_reporting_change_review/review.py new file mode 100644 index 000000000..17346c373 --- /dev/null +++ b/packages/position-reporting-change-review/src/orgmetra_position_reporting_change_review/review.py @@ -0,0 +1,321 @@ +"""Governed position-reporting change review evidence. + +This package defines a value-minimized pre-mutation review packet for changing +one Position-to-Position solid-line reporting relationship. It does not mutate +HRIS truth or authorize an employment decision. +""" + +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 WeakKeyDictionary + +_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$") +_DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_MAX_UUID_INT = (1 << 128) - 1 +_TIMEZONE_TYPE = type(timezone.utc) +_PURPOSE_CODE = "position_reporting_change_review" +_ALLOWED_REASON_CODES = frozenset( + { + "manager_vacancy", + "operating_model_change", + "organizational_realignment", + "span_of_control_adjustment", + } +) +_REVIEW_STATE = "requires_human_review" +_SCOPE_STATE = "requires_authoritative_resolution" +_MUTATION_STATE = "not_authorized_to_apply" +_DECISION_AUTHORITY = "human_review_only" +_NEXT_ACTION = ( + "Within tenant_record_id, re-resolve the subordinate, current-manager, and proposed-manager " + "Position records and the current solid-line reporting relationship through authoritative " + "Orgmetra HRIS boundaries at effective_on and the current system-recorded cutoff; prove all " + "positions are same-tenant, valid and staffable, prove requester/reviewer authoritative actor " + "separation, reject cycles or multiple visible solid-line managers, verify the reviewed scope " + "digests and reason, then invoke the authoritative reporting-line mutation boundary with " + "immutable audit/outbox evidence. This packet is review evidence only and is not authorization " + "to mutate HRIS truth or make an employment decision." +) + +_REGISTRY_LOCK = RLock() +_CREATION_DIGESTS: WeakKeyDictionary[PositionReportingChangeReviewPacket, str] = WeakKeyDictionary() + + +def _validate_operational_uuid_text(value: object, field_name: str) -> None: + """Require exact canonical non-sentinel UUID text owned by the HRIS boundary.""" + 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, _MAX_UUID_INT): + raise ValueError(f"{field_name} must be a canonical operational UUID") + + +def _validate_reference( + value: object, + prefix: str, + field_name: str, + *, + require_uuid4: bool, +) -> None: + """Require one bounded namespaced canonical UUID reference.""" + error = f"{field_name} must be a canonical {prefix}: reference" + if type(value) is not str or len(value) > 160 or not value.startswith(f"{prefix}:"): + raise ValueError(error) + suffix = value[len(prefix) + 1 :] + try: + parsed = UUID(suffix) + except (ValueError, AttributeError, TypeError) as exc: + raise ValueError(error) from exc + if str(parsed) != suffix or parsed.int in (0, _MAX_UUID_INT): + raise ValueError(error) + if require_uuid4 and parsed.version != 4: + raise ValueError(error) + + +def _validate_digest(value: object, field_name: str) -> None: + """Require exact built-in lowercase SHA-256 hexadecimal evidence.""" + if type(value) is not str or not _DIGEST_PATTERN.fullmatch(value): + raise ValueError(f"{field_name} must be lowercase SHA-256 hex") + + +def _validate_code(value: object, field_name: str) -> None: + """Require exact bounded two-or-more-word lower snake_case governance text.""" + if type(value) is not str or len(value) > 64 or not _CODE_PATTERN.fullmatch(value): + raise ValueError(f"{field_name} must be bounded lower snake_case governance text") + + +def _validate_positive_int(value: object, field_name: str) -> None: + """Require one exact positive bounded built-in integer.""" + if type(value) is not int or value < 1 or value > 2_147_483_647: + raise ValueError(f"{field_name} must be a positive 32-bit integer") + + +def _canonical_date(value: object) -> str: + """Render one exact built-in business date.""" + if type(value) is not date: + raise ValueError("effective_on must be an exact date") + return value.isoformat() + + +def _canonical_timestamp(value: object) -> str: + """Render one exact datetime with a built-in fixed offset as UTC RFC 3339 text.""" + if type(value) is not datetime or type(value.tzinfo) is not _TIMEZONE_TYPE: + raise ValueError("recorded_at must use a built-in fixed-offset timezone") + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _payload(packet: PositionReportingChangeReviewPacket) -> dict[str, object]: + """Snapshot all trust-bearing fields once for validation and canonical emission.""" + return { + "contains_employment_decision": packet.contains_employment_decision, + "contains_person_identifier": packet.contains_person_identifier, + "contains_worker_value": packet.contains_worker_value, + "decision_authority": packet.decision_authority, + "effective_on": _canonical_date(packet.effective_on), + "evidence_version": packet.evidence_version, + "human_review_required": packet.human_review_required, + "mutation_state": packet.mutation_state, + "next_action": packet.next_action, + "organization_scope_snapshot_digest": packet.organization_scope_snapshot_digest, + "position_reporting_change_reference": packet.position_reporting_change_reference, + "position_scope_snapshot_digest": packet.position_scope_snapshot_digest, + "proposed_manager_position_reference": packet.proposed_manager_position_reference, + "purpose_code": packet.purpose_code, + "reason_code": packet.reason_code, + "recorded_at": _canonical_timestamp(packet.recorded_at), + "requester_reference": packet.requester_reference, + "review_state": packet.review_state, + "reviewer_reference": packet.reviewer_reference, + "scope_verification_state": packet.scope_verification_state, + "subordinate_position_reference": packet.subordinate_position_reference, + "current_manager_position_reference": packet.current_manager_position_reference, + "tenant_record_id": packet.tenant_record_id, + } + + +def _canonical_payload_json(payload: dict[str, object]) -> str: + """Serialize one already-snapshotted payload deterministically.""" + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +@dataclass(frozen=True, slots=True, repr=False, eq=False, weakref_slot=True) +class PositionReportingChangeReviewPacket: + """PII-minimized human-review evidence for one reporting-line reassignment.""" + + tenant_record_id: str + position_reporting_change_reference: str + subordinate_position_reference: str + current_manager_position_reference: str + proposed_manager_position_reference: str + effective_on: date + position_scope_snapshot_digest: str + organization_scope_snapshot_digest: str + requester_reference: str + reviewer_reference: str + purpose_code: str + reason_code: str + recorded_at: datetime + evidence_version: int = 1 + contains_person_identifier: bool = False + contains_worker_value: bool = False + contains_employment_decision: bool = False + human_review_required: bool = True + review_state: str = _REVIEW_STATE + scope_verification_state: str = _SCOPE_STATE + mutation_state: str = _MUTATION_STATE + decision_authority: str = _DECISION_AUTHORITY + next_action: str = _NEXT_ACTION + + def __repr__(self) -> str: + """Return a representation that never emits reporting correlations.""" + return "PositionReportingChangeReviewPacket()" + + def __post_init__(self) -> None: + """Validate the review contract and seal its creation-time evidence.""" + _validate_operational_uuid_text(self.tenant_record_id, "tenant_record_id") + _validate_reference( + self.position_reporting_change_reference, + "position_reporting_change", + "position_reporting_change_reference", + require_uuid4=True, + ) + _validate_reference( + self.subordinate_position_reference, + "position_record", + "subordinate_position_reference", + require_uuid4=False, + ) + _validate_reference( + self.current_manager_position_reference, + "position_record", + "current_manager_position_reference", + require_uuid4=False, + ) + _validate_reference( + self.proposed_manager_position_reference, + "position_record", + "proposed_manager_position_reference", + require_uuid4=False, + ) + if self.subordinate_position_reference == self.current_manager_position_reference: + raise ValueError("subordinate position must differ from the current manager position") + if self.subordinate_position_reference == self.proposed_manager_position_reference: + raise ValueError("subordinate position must differ from the proposed manager position") + if self.current_manager_position_reference == self.proposed_manager_position_reference: + raise ValueError("proposed manager position must differ from the current manager position") + _canonical_date(self.effective_on) + _validate_digest(self.position_scope_snapshot_digest, "position_scope_snapshot_digest") + _validate_digest( + self.organization_scope_snapshot_digest, + "organization_scope_snapshot_digest", + ) + _validate_reference( + self.requester_reference, + "actor", + "requester_reference", + require_uuid4=True, + ) + _validate_reference( + self.reviewer_reference, + "actor", + "reviewer_reference", + require_uuid4=True, + ) + if self.requester_reference == self.reviewer_reference: + raise ValueError("reviewer_reference must identify a different accountable actor") + _validate_code(self.purpose_code, "purpose_code") + if self.purpose_code != _PURPOSE_CODE: + raise ValueError("purpose_code must remain position_reporting_change_review") + _validate_code(self.reason_code, "reason_code") + if self.reason_code not in _ALLOWED_REASON_CODES: + raise ValueError("reason_code must use the reviewed reporting-change vocabulary") + _canonical_timestamp(self.recorded_at) + _validate_positive_int(self.evidence_version, "evidence_version") + if self.contains_person_identifier is not False: + raise ValueError("reporting-change evidence must not contain a person identifier") + if self.contains_worker_value is not False: + raise ValueError("reporting-change evidence must not contain worker values") + if self.contains_employment_decision is not False: + raise ValueError("reporting-change evidence must not contain an employment decision") + if self.human_review_required is not True: + raise ValueError("human review is mandatory before reporting-line mutation") + _validate_code(self.review_state, "review_state") + if self.review_state != _REVIEW_STATE: + raise ValueError("review_state must remain requires_human_review") + _validate_code(self.scope_verification_state, "scope_verification_state") + if self.scope_verification_state != _SCOPE_STATE: + raise ValueError("scope_verification_state must remain requires_authoritative_resolution") + _validate_code(self.mutation_state, "mutation_state") + if self.mutation_state != _MUTATION_STATE: + raise ValueError("mutation_state must remain not_authorized_to_apply") + _validate_code(self.decision_authority, "decision_authority") + if self.decision_authority != _DECISION_AUTHORITY: + raise ValueError("decision_authority must remain human_review_only") + if type(self.next_action) is not str or self.next_action != _NEXT_ACTION: + raise ValueError("next_action must remain the governed reporting-change instruction") + + payload_json = _canonical_payload_json(_payload(self)) + creation_digest = sha256(payload_json.encode("utf-8")).hexdigest() + with _REGISTRY_LOCK: + _CREATION_DIGESTS[self] = creation_digest + + def canonical_json(self) -> str: + """Return one verified snapshot of deterministic canonical audit evidence.""" + payload = _payload(self) + payload_json = _canonical_payload_json(payload) + current_digest = sha256(payload_json.encode("utf-8")).hexdigest() + with _REGISTRY_LOCK: + creation_digest = _CREATION_DIGESTS.get(self) + if current_digest != creation_digest: + raise ValueError("position reporting-change evidence changed after issuance") + return payload_json + + def sha256_digest(self) -> str: + """Return SHA-256 over the exact verified canonical UTF-8 evidence.""" + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +def build_position_reporting_change_review_packet( + *, + tenant_record_id: str, + position_reporting_change_reference: str, + subordinate_position_reference: str, + current_manager_position_reference: str, + proposed_manager_position_reference: str, + effective_on: date, + position_scope_snapshot_digest: str, + organization_scope_snapshot_digest: str, + requester_reference: str, + reviewer_reference: str, + purpose_code: str, + reason_code: str, + recorded_at: datetime, + evidence_version: int = 1, +) -> PositionReportingChangeReviewPacket: + """Build value-minimized reporting-change evidence pending authoritative mutation.""" + return PositionReportingChangeReviewPacket( + tenant_record_id=tenant_record_id, + position_reporting_change_reference=position_reporting_change_reference, + subordinate_position_reference=subordinate_position_reference, + current_manager_position_reference=current_manager_position_reference, + proposed_manager_position_reference=proposed_manager_position_reference, + effective_on=effective_on, + position_scope_snapshot_digest=position_scope_snapshot_digest, + organization_scope_snapshot_digest=organization_scope_snapshot_digest, + requester_reference=requester_reference, + reviewer_reference=reviewer_reference, + purpose_code=purpose_code, + reason_code=reason_code, + recorded_at=recorded_at, + evidence_version=evidence_version, + ) diff --git a/packages/position-reporting-change-review/tests/test_repository_contract.py b/packages/position-reporting-change-review/tests/test_repository_contract.py new file mode 100644 index 000000000..613d39bd8 --- /dev/null +++ b/packages/position-reporting-change-review/tests/test_repository_contract.py @@ -0,0 +1,27 @@ +"""Repository-level quality contract for the Position reporting-change review lane.""" + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +FOUNDATION_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/foundation-ci.yml" +RETIRED_LEAF_WORKFLOW = ( + REPOSITORY_ROOT / ".github/workflows/position-reporting-change-review-quality.yml" +) +_VENV_PATH = "/tmp/orgmetra-position-reporting-change-review-venv" + + +def test_canonical_foundation_executes_installed_artifact_contract() -> None: + """Keep exact installed-wheel quality in Foundation without recreating a leaf workflow.""" + workflow = FOUNDATION_WORKFLOW.read_text(encoding="utf-8") + + assert not RETIRED_LEAF_WORKFLOW.exists() + assert "Run Position reporting change review installed-artifact contract" in workflow + assert 'python-version: "3.14.7"' in workflow + assert f"python -m venv {_VENV_PATH}" in workflow + assert 'wheel_sha="$(sha256sum "$wheel_path" | awk \'{print $1}\')"' in workflow + assert ( + f"{_VENV_PATH}/bin/python -m pytest " + '-c "$GITHUB_WORKSPACE/packages/position-reporting-change-review/pyproject.toml"' + in workflow + ) diff --git a/packages/position-reporting-change-review/tests/test_review.py b/packages/position-reporting-change-review/tests/test_review.py new file mode 100644 index 000000000..d27311497 --- /dev/null +++ b/packages/position-reporting-change-review/tests/test_review.py @@ -0,0 +1,254 @@ +"""Executable contract for governed Position reporting-change review evidence.""" + +from datetime import date, datetime, timedelta, timezone +import json +from uuid import UUID + +import pytest + +from orgmetra_position_reporting_change_review import ( + PositionReportingChangeReviewPacket, + build_position_reporting_change_review_packet, +) + +TENANT_UUID7 = "0195c23d-9f00-7000-8000-000000000001" +CHANGE_UUID4 = "11111111-1111-4111-8111-111111111111" +SUBORDINATE_UUID7 = "0195c23d-9f00-7000-8000-000000000002" +CURRENT_MANAGER_UUID7 = "0195c23d-9f00-7000-8000-000000000003" +PROPOSED_MANAGER_UUID7 = "0195c23d-9f00-7000-8000-000000000004" +REQUESTER_UUID4 = "22222222-2222-4222-8222-222222222222" +REVIEWER_UUID4 = "33333333-3333-4333-8333-333333333333" +DIGEST_A = "a" * 64 +DIGEST_B = "b" * 64 + + +def values() -> dict[str, object]: + """Return one valid reporting-change review input set.""" + return { + "tenant_record_id": TENANT_UUID7, + "position_reporting_change_reference": f"position_reporting_change:{CHANGE_UUID4}", + "subordinate_position_reference": f"position_record:{SUBORDINATE_UUID7}", + "current_manager_position_reference": f"position_record:{CURRENT_MANAGER_UUID7}", + "proposed_manager_position_reference": f"position_record:{PROPOSED_MANAGER_UUID7}", + "effective_on": date(2026, 9, 1), + "position_scope_snapshot_digest": DIGEST_A, + "organization_scope_snapshot_digest": DIGEST_B, + "requester_reference": f"actor:{REQUESTER_UUID4}", + "reviewer_reference": f"actor:{REVIEWER_UUID4}", + "purpose_code": "position_reporting_change_review", + "reason_code": "organizational_realignment", + "recorded_at": datetime(2026, 8, 23, 6, 0, 0, 123456, tzinfo=timezone.utc), + "evidence_version": 1, + } + + +def build(**overrides: object) -> PositionReportingChangeReviewPacket: + """Build a packet after applying explicit test overrides.""" + inputs = values() + inputs.update(overrides) + return build_position_reporting_change_review_packet(**inputs) + + +def test_builds_value_minimized_human_review_packet() -> None: + """Bind reporting scope without copying Person or worker values.""" + packet = build() + payload = json.loads(packet.canonical_json()) + assert payload["review_state"] == "requires_human_review" + assert payload["scope_verification_state"] == "requires_authoritative_resolution" + assert payload["mutation_state"] == "not_authorized_to_apply" + assert payload["decision_authority"] == "human_review_only" + assert payload["contains_person_identifier"] is False + assert payload["contains_worker_value"] is False + assert payload["contains_employment_decision"] is False + assert payload["human_review_required"] is True + assert "person_record" not in packet.canonical_json() + assert "compensation" not in packet.canonical_json() + assert "rating" not in packet.canonical_json() + + +def test_canonical_evidence_is_deterministic_and_redacted_in_repr() -> None: + """Provide deterministic correlation evidence without log disclosure.""" + packet = build() + assert repr(packet) == "PositionReportingChangeReviewPacket()" + assert packet.sha256_digest() == packet.sha256_digest() + assert packet.canonical_json().endswith( + ',"tenant_record_id":"0195c23d-9f00-7000-8000-000000000001"}' + ) + assert payload_recorded_at(packet) == "2026-08-23T06:00:00.123456Z" + + +def payload_recorded_at(packet: PositionReportingChangeReviewPacket) -> str: + """Read the canonical recorded timestamp for assertions.""" + return json.loads(packet.canonical_json())["recorded_at"] + + +@pytest.mark.parametrize( + ("field_name", "invalid_value"), + [ + ("tenant_record_id", "not-a-uuid"), + ("tenant_record_id", "00000000-0000-0000-0000-000000000000"), + ("position_reporting_change_reference", "position_reporting_change:6ba7b810-9dad-11d1-80b4-00c04fd430c8"), + ("position_reporting_change_reference", "wrong:11111111-1111-4111-8111-111111111111"), + ("subordinate_position_reference", "position_record:not-a-uuid"), + ("current_manager_position_reference", "position_record:00000000-0000-0000-0000-000000000000"), + ("proposed_manager_position_reference", "x" * 161), + ("position_scope_snapshot_digest", "A" * 64), + ("organization_scope_snapshot_digest", "abc"), + ("requester_reference", "actor:6ba7b810-9dad-11d1-80b4-00c04fd430c8"), + ("reviewer_reference", "actor:not-a-uuid"), + ("purpose_code", "Position Reporting Change Review"), + ("purpose_code", "wrong_purpose"), + ("reason_code", "free_form_sensitive_reason"), + ("evidence_version", 0), + ("evidence_version", True), + ], +) +def test_rejects_invalid_trust_evidence(field_name: str, invalid_value: object) -> None: + """Reject malformed, correlating, or unreviewed trust-bearing evidence.""" + with pytest.raises(ValueError): + build(**{field_name: invalid_value}) + + +@pytest.mark.parametrize( + "overrides", + [ + {"current_manager_position_reference": f"position_record:{SUBORDINATE_UUID7}"}, + {"proposed_manager_position_reference": f"position_record:{SUBORDINATE_UUID7}"}, + {"proposed_manager_position_reference": f"position_record:{CURRENT_MANAGER_UUID7}"}, + {"reviewer_reference": f"actor:{REQUESTER_UUID4}"}, + ], +) +def test_rejects_ambiguous_reporting_or_actor_relationships(overrides: dict[str, object]) -> None: + """Reject self-reporting, no-op manager changes, and same-actor reviews.""" + with pytest.raises(ValueError): + build(**overrides) + + +@pytest.mark.parametrize( + "overrides", + [ + {"contains_person_identifier": True}, + {"contains_worker_value": True}, + {"contains_employment_decision": True}, + {"human_review_required": False}, + {"review_state": "approved_for_use"}, + {"scope_verification_state": "verified_scope"}, + {"mutation_state": "authorized_to_apply"}, + {"decision_authority": "automated_decision"}, + {"next_action": "apply immediately"}, + ], +) +def test_direct_construction_cannot_weaken_governance(overrides: dict[str, object]) -> None: + """Fail closed when direct construction tries to weaken governance constants.""" + inputs = values() + inputs.update(overrides) + with pytest.raises(ValueError): + PositionReportingChangeReviewPacket(**inputs) + + +def test_accepts_fixed_offset_timestamp_and_canonicalizes_to_utc() -> None: + """Normalize an exact built-in fixed-offset timestamp without losing precision.""" + recorded = datetime(2026, 8, 23, 15, 0, 0, 654321, tzinfo=timezone(timedelta(hours=9))) + packet = build(recorded_at=recorded) + assert payload_recorded_at(packet) == "2026-08-23T06:00:00.654321Z" + + +@pytest.mark.parametrize( + ("field_name", "invalid_value"), + [ + ("effective_on", datetime(2026, 9, 1, tzinfo=timezone.utc)), + ("recorded_at", datetime(2026, 8, 23, 6, 0, 0)), + ], +) +def test_rejects_noncanonical_temporal_primitives(field_name: str, invalid_value: object) -> None: + """Reject datetime-as-date and naive system-recorded timestamps.""" + with pytest.raises(ValueError): + build(**{field_name: invalid_value}) + + +class ForgedText(str): + """Attempt to forge equality, hashing, and namespace checks.""" + + def __eq__(self, other: object) -> bool: + """Pretend to equal every comparison target.""" + return True + + def __hash__(self) -> int: + """Return a stable attacker-controlled hash.""" + return 0 + + def startswith(self, prefix: str, *args: object) -> bool: + """Pretend to satisfy any namespace.""" + return True + + +class ForgedInt(int): + """Attempt to forge numeric comparisons.""" + + def __lt__(self, other: object) -> bool: + """Pretend never to be below a lower bound.""" + return False + + def __le__(self, other: object) -> bool: + """Pretend never to satisfy an inclusive lower comparison.""" + return False + + def __gt__(self, other: object) -> bool: + """Pretend never to exceed an upper bound.""" + return False + + def __ge__(self, other: object) -> bool: + """Pretend never to satisfy an inclusive upper comparison.""" + return False + + +class ForgedDate(date): + """Represent an untrusted caller-defined date subtype.""" + + +class ForgedDateTime(datetime): + """Represent an untrusted caller-defined datetime subtype.""" + + +@pytest.mark.parametrize( + ("field_name", "invalid_value"), + [ + ("tenant_record_id", ForgedText(TENANT_UUID7)), + ("purpose_code", ForgedText("shadow_purpose")), + ("reason_code", ForgedText("shadow_reason")), + ("position_reporting_change_reference", ForgedText("shadow_reference")), + ("evidence_version", ForgedInt(99)), + ("effective_on", ForgedDate(2026, 9, 1)), + ("recorded_at", ForgedDateTime(2026, 8, 23, 6, 0, tzinfo=timezone.utc)), + ], +) +def test_rejects_caller_defined_runtime_subclasses(field_name: str, invalid_value: object) -> None: + """Prevent caller polymorphism from changing checked-versus-emitted evidence.""" + with pytest.raises(ValueError): + build(**{field_name: invalid_value}) + + +def test_detects_post_construction_tampering_before_evidence_export() -> None: + """Reject mutation that bypasses frozen dataclass syntax.""" + packet = build() + object.__setattr__(packet, "reason_code", "manager_vacancy") + with pytest.raises(ValueError, match="changed after issuance"): + packet.canonical_json() + + +def test_next_action_preserves_authoritative_bitemporal_and_audit_boundary() -> None: + """Tell the host exactly what remains to prove before mutation.""" + next_action = json.loads(build().canonical_json())["next_action"] + assert "current system-recorded cutoff" in next_action + assert "same-tenant" in next_action + assert "reject cycles or multiple visible solid-line managers" in next_action + assert "immutable audit/outbox evidence" in next_action + assert "not authorization to mutate HRIS truth" in next_action + + +def test_operational_position_references_accept_uuid7() -> None: + """Interoperate with authoritative HRIS UUID evolution while keeping packet UUIDv4.""" + packet = build() + assert UUID(packet.tenant_record_id).version == 7 + assert UUID(packet.subordinate_position_reference.split(":", 1)[1]).version == 7 + assert UUID(packet.position_reporting_change_reference.split(":", 1)[1]).version == 4