diff --git a/docs/adr/0111-governed-position-lifecycle-review.md b/docs/adr/0111-governed-position-lifecycle-review.md new file mode 100644 index 000000000..3bb7e94f3 --- /dev/null +++ b/docs/adr/0111-governed-position-lifecycle-review.md @@ -0,0 +1,35 @@ +# ADR 0111: Govern Position lifecycle-change review separately from mutation + +Status: Proposed + +## Context + +Current `develop@eb9757f8649aaad026a9865508d9aad50c1a7a4f` product truth stores Job, Position, and Assignment separately and already recognizes Position lifecycle vocabulary, but the shipped People mutation path only creates Position records. Repository governance is a separate concern: effective organization ruleset `18156473` currently requires one approving review, stale-review dismissal after push, review-thread resolution, extra approval for unattributed changes, seven central required workflows, and non-fast-forward/deletion protection; `require_last_push_approval` is false. Issue #89 owns the remaining governance/control-plane gap, including routine `OrganizationAdmin/always` bypass. None of those repository controls is implied by this ADR, and their live payload must be re-read before integration/release claims. Vacancy evidence and vacancy-to-assignment orchestration do not own a review contract for freezing, closing, abolishing, or reactivating an existing seat. + +A lifecycle change can affect staffing availability and later workforce evidence. Reusing a generic Position-creation command or a reporting-line review would blur evidence ownership and could let cached/UI state substitute for current bitemporal truth. + +## Decision + +Add an Orgmetra-owned `PositionLifecycleChangeReviewPacket` as a transport-neutral, value-minimized human-review artifact. + +The packet binds one tenant-qualified Position, current/proposed lifecycle state, business-effective date, reviewed Position/Assignment snapshot digests, pseudonymous requester/reviewer separation, controlled reason/outcome, evidence version, and human-review/system-recorded UTC chronology. `abolished` is terminal and no-op transitions are rejected. + +The packet never authorizes mutation. An approved review remains `requires_authoritative_resolution` and `not_authorized_to_apply`. The later authoritative host must freshly resolve bitemporal Position and Assignment truth, re-establish actor authority/separation and staffing safety, validate the reviewed evidence, and commit the mutation with immutable audit/outbox. + +Package-level quality remains subordinate to protected repository ownership: the retired Position Lifecycle Review leaf workflow stays deleted. Its useful exact CPython 3.14.7, installed-wheel provenance, and 100% statement/branch coverage obligations execute through canonical Foundation CI with an executable non-resurrection regression. + +## Consequences + +- Lifecycle review evidence cannot silently become Position truth. +- Existing Position/Assignment source-of-truth boundaries remain unchanged. +- No Person/candidate identity, compensation, assessment, rating, allocation value, credential, prompt, or model output is copied into the review evidence. +- A later bounded mutation/persistence slice remains necessary; this ADR does not claim it is shipped. +- Process-local issuance/reference binding is defense in depth, not distributed durability or authorization. +- Conflict-free Git protected-parent adoption is insufficient if it resurrects a workflow retired by the protected owner; semantic reconciliation must preserve the protected owner and the useful evidence contract separately. + +## Alternatives rejected + +1. **Encode lifecycle change as a new Position creation.** Rejected because it would conflate stable Position identity with versioned lifecycle state. +2. **Reuse vacancy or reporting-line review evidence.** Rejected because those artifacts prove different facts and do not own lifecycle semantics. +3. **Allow the review packet itself to mutate Position truth.** Rejected because current Position/Assignment truth and staffing safety must be re-resolved at the authoritative transaction boundary. +4. **Restore the package-local quality workflow during protected-parent adoption.** Rejected because protected #161 centralized repository-owned PR quality and pinned the canonical runner contract; the package's useful artifact/coverage obligation belongs under that owner rather than in a resurrected leaf. diff --git a/docs/doctoring/position-lifecycle-review-references.md b/docs/doctoring/position-lifecycle-review-references.md new file mode 100644 index 000000000..fae0f872c --- /dev/null +++ b/docs/doctoring/position-lifecycle-review-references.md @@ -0,0 +1,17 @@ +# Position lifecycle review — primary references + +Reviewed 2026-08-24 (UTC/KST calendar date as applicable). These references support identifier, privacy-risk, and workforce-evidence design only; Orgmetra does not claim certification or reproduce licensed ISO metric definitions. + +## APA 7 references + +Davis, K., Peabody, B., & Leach, P. (2024). *Universally Unique IDentifiers (UUIDs)* (RFC 9562). RFC Editor. https://doi.org/10.17487/RFC9562 + +International Organization for Standardization. (2025). *Human resource management—Requirements and recommendations for human capital reporting and disclosure* (ISO 30414:2025, 2nd ed.). https://www.iso.org/standard/30414 + +National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management, version 1.0* (NIST CSWP 10). https://doi.org/10.6028/NIST.CSWP.10 + +## Design use + +- RFC 9562 is the primary identifier source for UUID layouts, including UUIDv4, UUIDv7, Nil, and Max. Orgmetra keeps authoritative HRIS operational UUID interoperability separate from packet-owned UUIDv4 correlation identifiers. +- ISO 30414:2025 is currently published as Edition 2 (2025-08) and includes workforce composition and mobility/succession among its human-capital reporting areas. It motivates historically defensible workforce/Position evidence, not a licensed metric implementation or conformity claim. +- NIST Privacy Framework 1.0 is a final, voluntary risk-management framework. It motivates value minimization and separating durable governance evidence from unnecessary Person/candidate/compensation payloads. NIST also lists Privacy Framework 1.1 as a newer project; this ADR does not present non-final work as the final baseline. diff --git a/docs/traceability/position-lifecycle-review.md b/docs/traceability/position-lifecycle-review.md new file mode 100644 index 000000000..8753b3af7 --- /dev/null +++ b/docs/traceability/position-lifecycle-review.md @@ -0,0 +1,27 @@ +# Position lifecycle review traceability + +## Truth state + +- **Default-branch product truth:** `develop@eb9757f8649aaad026a9865508d9aad50c1a7a4f` keeps Job, Position, and Assignment as distinct HRIS facts, owns Position lifecycle vocabulary, and carries purpose-bound authorization plus immutable audit/outbox foundations. It does not yet integrate this active PR's dedicated existing-Position lifecycle review artifact or authoritative lifecycle mutation. +- **Repository governance truth:** effective organization ruleset `18156473` applies to the default branch. The live payload requires one approving review, stale-review dismissal after push, review-thread resolution, extra approval for unattributed changes, seven central required workflows, and deletion/non-fast-forward protection; `require_last_push_approval` is currently false. Issue #89 owns the remaining governance/control-plane gap, including routine `OrganizationAdmin/always` bypass. These repository controls are not product capability and must be re-read before merge/release claims. +- **Active PR truth:** this branch adds only the governed review evidence packet and an exact-head installed-artifact/coverage contract consolidated under canonical Foundation CI. +- **Planned:** authoritative bitemporal Position lifecycle mutation/persistence that consumes reviewed evidence, re-resolves staffing truth, and atomically records immutable audit/outbox. +- **Out of scope:** autonomous employment decisions, Person/candidate data, compensation, assessment/rating data, reporting-line mutation, Keyverse/Naruon/other CWL repository writes, and direct foreign application-table SQL. + +## Requirement mapping + +| Requirement | Evidence | +|---|---| +| Separate Position identity from lifecycle review evidence | `PositionLifecycleChangeReviewPacket`; ADR 0111 | +| Preserve business-effective and system-recorded time | `effective_on`, `reviewed_at`, `recorded_at`; chronology regressions | +| Human review with requester/reviewer separation | pseudonymous actor UUIDv4 correlations; separation regression | +| Purposefully non-authorizing high-impact evidence | fixed `human_reviewed`, `requires_authoritative_resolution`, `not_authorized_to_apply`, `human_review_only` states | +| Re-resolve staffing truth before mutation | reviewed Position/Assignment snapshot SHA-256 digests plus fixed approved next action | +| Minimize PII and high-impact payloads | packet carries no Person/candidate identity, allocation, compensation, rating, assessment, free text, credential, prompt, or model output | +| Fail closed on lifecycle ambiguity | explicit transition vocabulary; no-op rejection; abolished terminal; reason bound to target status | +| Tamper/correlation defense in depth | issuance digest verification; live tenant-qualified review-reference binding; adversarial `object.__setattr__` and `dataclasses.replace` regressions | +| Exact owned test coverage without retired leaf CI | canonical Foundation delegates to `tests/test_position_lifecycle_review_artifact.sh`, which requires CPython 3.14.7, hash-bound isolated wheel installation, and the package's exact `--cov-branch --cov-fail-under=100` contract; `test_artifact_execution.py` rejects leaf-workflow resurrection | + +## Buyer behavior + +A buyer-facing workflow can collect a review of a proposed Position freeze/closure/abolition/reactivation without claiming the seat has changed. If the review outcome is rejected, the only next action is not to apply it. If approved for authoritative resolution, the next action explicitly requires fresh tenant-qualified Position/Assignment truth, actor authority/separation, staffing safety, evidence validation, and immutable audit/outbox before any mutation. diff --git a/packages/position-lifecycle-review/CHANGELOG.md b/packages/position-lifecycle-review/CHANGELOG.md new file mode 100644 index 000000000..c147c4cd3 --- /dev/null +++ b/packages/position-lifecycle-review/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.1.0 - Unreleased + +- Add `PositionLifecycleChangeReviewPacket` for value-minimized human review of existing Position status changes. +- Keep all reviewed evidence non-authorizing until fresh authoritative bitemporal Position/Assignment resolution and immutable audit/outbox mutation. +- Reject no-op transitions, resurrection of abolished Positions, ungoverned status/reason/outcome vocabulary, noncanonical actor/digest/time evidence, hostile runtime scalar subclasses, post-issuance mutation, and conflicting live change-reference reuse. +- Preserve the installed-wheel quality contract under canonical Foundation CI instead of restoring the retired package-local workflow: exact CPython 3.14.7, hash-bound isolated wheel installation, and 100% owned statement/branch coverage remain required. diff --git a/packages/position-lifecycle-review/README.md b/packages/position-lifecycle-review/README.md new file mode 100644 index 000000000..9a1f44868 --- /dev/null +++ b/packages/position-lifecycle-review/README.md @@ -0,0 +1,45 @@ +# Orgmetra Position Lifecycle Review + +This package records **human-reviewed evidence for a proposed lifecycle change to an existing Position**. It is deliberately not the authoritative Position mutation boundary. + +## Why it exists + +Current Orgmetra `develop@eb9757f8649aaad026a9865508d9aad50c1a7a4f` product truth already treats `Job`, `Position`, and `Assignment` as separate HRIS facts and recognizes Position statuses `open`, `active`, `frozen`, `closed`, and `abolished`. Repository control is separate from that product claim: effective organization ruleset `18156473` currently requires one approving review, stale-review dismissal after push, review-thread resolution, extra approval for unattributed changes, seven central required workflows, and non-fast-forward/deletion protection; last-push approval is not currently required. Repository-governance issue #89 remains open for the control-plane gap, including routine `OrganizationAdmin/always` bypass. Those controls must be re-read before merge/release claims and do not make this unmerged package protected product truth. + +A commercial HRIS also needs review evidence before a seat is frozen, closed, abolished, or reactivated, because those changes can alter staffing availability and downstream workforce reporting. + +`PositionLifecycleChangeReviewPacket` binds: + +- tenant and Position operational UUIDs; +- one packet-owned UUIDv4 change correlation; +- current/proposed lifecycle status and business-effective date; +- exact reviewed Position and Assignment snapshot SHA-256 digests; +- pseudonymous requester/reviewer UUIDv4 actor correlations with separation; +- controlled reason and human review outcome; +- evidence schema version 1; +- human-review time and later-or-equal system-recorded UTC time. + +It intentionally carries **no Person/candidate identity, name, email, compensation, assessment, rating, allocation value, credential, prompt, or model output**. + +## Authority boundary + +Even an `approved_for_authoritative_resolution` review remains: + +- `human_reviewed`; +- `requires_authoritative_resolution`; +- `not_authorized_to_apply`; +- `human_review_only`. + +Before a lifecycle mutation, the Orgmetra host must freshly re-resolve tenant-qualified bitemporal Position and Assignment truth at the requested business/system coordinate, re-resolve requester/reviewer authority, prove staffing safety, validate the reviewed evidence, and commit the actual Position mutation with immutable audit/outbox in the authoritative transaction. A rejected review must not be applied. + +The packet permits reviewed transitions among normal seat states but treats `abolished` as terminal. No-op status changes are rejected. A reason token is tied to the proposed state so a valid token cannot be reused with different lifecycle semantics. + +## Integrity boundary + +Trust-bearing scalar values require exact built-in runtime types. Canonical evidence is deterministic JSON with SHA-256 content digest and a redacted routine representation. A process-local issuance seal detects post-construction field rewriting, while a live tenant-qualified change-reference binding prevents one correlation from denoting conflicting evidence while any duplicate packet remains alive. These are defense-in-depth controls only; durable uniqueness and mutation authorization belong to authoritative persistence/audit. + +Package-quality evidence is owned by canonical Foundation CI rather than a package-local workflow. The active branch keeps the retired `position-lifecycle-review-quality.yml` absent and uses a repository-owned artifact contract to require CPython 3.14.7, a hash-bound isolated wheel installation, and exact 100% statement/branch coverage without package-local `PYTHONPATH` execution. + +## Scope + +This package writes only Orgmetra evidence. It does not modify Keyverse, Naruon, Contextual Orchestrator, or another dedicated-writer repository and does not query a foreign application table. diff --git a/packages/position-lifecycle-review/pyproject.toml b/packages/position-lifecycle-review/pyproject.toml new file mode 100644 index 000000000..e549123cc --- /dev/null +++ b/packages/position-lifecycle-review/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=84"] +build-backend = "setuptools.build_meta" + +[project] +name = "orgmetra-position-lifecycle-review" +version = "0.1.0" +description = "Governed Position lifecycle-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_lifecycle_review", + "--cov-branch", + "--cov-report=term-missing", + "--cov-fail-under=100", +] diff --git a/packages/position-lifecycle-review/src/orgmetra_position_lifecycle_review/__init__.py b/packages/position-lifecycle-review/src/orgmetra_position_lifecycle_review/__init__.py new file mode 100644 index 000000000..7ac34d88e --- /dev/null +++ b/packages/position-lifecycle-review/src/orgmetra_position_lifecycle_review/__init__.py @@ -0,0 +1,5 @@ +"""Public API for governed Position lifecycle-change review evidence.""" + +from .review import PositionLifecycleChangeReviewPacket + +__all__ = ["PositionLifecycleChangeReviewPacket"] diff --git a/packages/position-lifecycle-review/src/orgmetra_position_lifecycle_review/review.py b/packages/position-lifecycle-review/src/orgmetra_position_lifecycle_review/review.py new file mode 100644 index 000000000..c01c776d8 --- /dev/null +++ b/packages/position-lifecycle-review/src/orgmetra_position_lifecycle_review/review.py @@ -0,0 +1,237 @@ +"""Governed, value-minimized evidence for reviewing a Position lifecycle change. + +This package deliberately does not mutate Position truth. It records one human +review of a proposed lifecycle transition and remains fail-closed until an +Orgmetra authoritative host re-resolves current Position and Assignment truth, +re-authorizes actors, and persists the later mutation with immutable audit/outbox. +""" + +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 +import weakref +from uuid import UUID + +_MAX_UUID_INT = (1 << 128) - 1 +_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_STATUSES = frozenset({"active", "open", "closed", "frozen", "abolished"}) +_REASONS = frozenset({"temporary_freeze", "position_reactivation", "position_closure", "position_abolition"}) +_OUTCOMES = frozenset({"approved_for_authoritative_resolution", "rejected"}) +_ALLOWED_TRANSITIONS = { + "open": frozenset({"active", "frozen", "closed", "abolished"}), + "active": frozenset({"frozen", "closed", "abolished"}), + "frozen": frozenset({"open", "active", "closed", "abolished"}), + "closed": frozenset({"open", "abolished"}), + "abolished": frozenset(), +} +_EXPECTED_REASON = { + "active": "position_reactivation", + "open": "position_reactivation", + "frozen": "temporary_freeze", + "closed": "position_closure", + "abolished": "position_abolition", +} +_LOCK = RLock() +_ISSUANCE_DIGESTS: dict[int, str] = {} +_REFERENCE_BINDINGS: dict[tuple[UUID, UUID], tuple[str, int]] = {} + + +def _require_operational_uuid(field_name: str, value: object) -> UUID: + """Return one exact operational UUID while rejecting protocol sentinels.""" + if type(value) is not UUID or value.int in (0, _MAX_UUID_INT): + raise ValueError(f"{field_name} must be an operational UUID.") + return value + + +def _require_uuid4(field_name: str, value: object) -> UUID: + """Return one exact UUIDv4 owned by this evidence packet.""" + if type(value) is not UUID or value.version != 4: + raise ValueError(f"{field_name} must be a UUIDv4.") + return value + + +def _require_text(field_name: str, value: object, allowed: frozenset[str]) -> str: + """Require exact built-in governance text before membership evaluation.""" + if type(value) is not str: + raise TypeError(f"{field_name} must be exact built-in str.") + if value not in allowed: + raise ValueError(f"{field_name} is not in the governed vocabulary.") + return value + + +def _require_digest(field_name: str, value: object) -> str: + """Require the lowercase textual form of one SHA-256 digest.""" + if type(value) is not str: + raise TypeError(f"{field_name} must be exact built-in str.") + if _SHA256_PATTERN.fullmatch(value) is None: + raise ValueError(f"{field_name} must be a lowercase SHA-256 digest.") + return value + + +def _require_actor_reference(field_name: str, value: object) -> str: + """Require one Orgmetra-local pseudonymous actor UUIDv4 correlation.""" + if type(value) is not str or not value.startswith("actor:"): + raise ValueError(f"{field_name} must be an actor: UUIDv4 correlation.") + suffix = value.removeprefix("actor:") + try: + parsed = UUID(suffix) + except ValueError as error: + raise ValueError(f"{field_name} must be an actor: UUIDv4 correlation.") from error + if parsed.version != 4 or str(parsed) != suffix: + raise ValueError(f"{field_name} must be an actor: UUIDv4 correlation.") + return value + + +def _require_utc(field_name: str, value: object) -> datetime: + """Require an exact built-in UTC datetime already detached from caller timezone code.""" + if type(value) is not datetime or value.tzinfo is not timezone.utc: + raise ValueError(f"{field_name} must be an exact UTC datetime.") + return value + + +def _utc_text(value: datetime) -> str: + """Return deterministic RFC 3339-compatible UTC text.""" + return value.isoformat().replace("+00:00", "Z") + + +def _canonical_bytes(document: dict[str, object]) -> bytes: + """Serialize one canonical evidence document deterministically.""" + return json.dumps(document, separators=(",", ":"), sort_keys=True, ensure_ascii=False).encode("utf-8") + + +def _release_binding(object_id: int, key: tuple[UUID, UUID], digest: str) -> None: + """Release one process-local issuance/reference binding after packet collection.""" + with _LOCK: + _ISSUANCE_DIGESTS.pop(object_id, None) + bound_digest, count = _REFERENCE_BINDINGS[key] + if bound_digest != digest: + raise AssertionError("reference binding digest drifted from its issuance") + if count == 1: + del _REFERENCE_BINDINGS[key] + else: + _REFERENCE_BINDINGS[key] = (digest, count - 1) + + +@dataclass(frozen=True, slots=True, weakref_slot=True) +class PositionLifecycleChangeReviewPacket: + """Human-reviewed, non-authorizing evidence for one Position lifecycle proposal.""" + + tenant_record_id: UUID + position_record_id: UUID + position_lifecycle_change_reference: UUID + current_status_code: str + proposed_status_code: str + effective_on: date + position_snapshot_digest_sha256: str + assignment_snapshot_digest_sha256: str + requester_actor_reference: str + reviewer_actor_reference: str + reason_code: str + review_outcome_code: str + evidence_version: int + reviewed_at: datetime + recorded_at: datetime + + def __post_init__(self) -> None: + """Validate reviewed evidence and seal live correlation semantics.""" + _require_operational_uuid("tenant_record_id", self.tenant_record_id) + _require_operational_uuid("position_record_id", self.position_record_id) + _require_uuid4("position_lifecycle_change_reference", self.position_lifecycle_change_reference) + current = _require_text("current_status_code", self.current_status_code, _STATUSES) + proposed = _require_text("proposed_status_code", self.proposed_status_code, _STATUSES) + reason = _require_text("reason_code", self.reason_code, _REASONS) + _require_text("review_outcome_code", self.review_outcome_code, _OUTCOMES) + if proposed not in _ALLOWED_TRANSITIONS[current]: + raise ValueError("proposed_status_code is not an allowed reviewed transition from current_status_code.") + if reason != _EXPECTED_REASON[proposed]: + raise ValueError("reason_code does not match the proposed Position lifecycle state.") + if type(self.effective_on) is not date: + raise TypeError("effective_on must be an exact business date.") + _require_digest("position_snapshot_digest_sha256", self.position_snapshot_digest_sha256) + _require_digest("assignment_snapshot_digest_sha256", self.assignment_snapshot_digest_sha256) + requester = _require_actor_reference("requester_actor_reference", self.requester_actor_reference) + reviewer = _require_actor_reference("reviewer_actor_reference", self.reviewer_actor_reference) + if requester == reviewer: + raise ValueError("requester and reviewer must be distinct pseudonymous actors.") + if type(self.evidence_version) is not int or self.evidence_version != 1: + raise ValueError("evidence_version must be exact integer 1.") + reviewed_at = _require_utc("reviewed_at", self.reviewed_at) + recorded_at = _require_utc("recorded_at", self.recorded_at) + if recorded_at < reviewed_at: + raise ValueError("recorded_at must be at or after reviewed_at.") + + document = self._payload() + digest = sha256(_canonical_bytes(document)).hexdigest() + key = (self.tenant_record_id, self.position_lifecycle_change_reference) + with _LOCK: + existing = _REFERENCE_BINDINGS.get(key) + if existing is None: + _REFERENCE_BINDINGS[key] = (digest, 1) + else: + existing_digest, count = existing + if existing_digest != digest: + raise ValueError("position lifecycle change reference is already bound to conflicting live evidence.") + _REFERENCE_BINDINGS[key] = (digest, count + 1) + _ISSUANCE_DIGESTS[id(self)] = digest + weakref.finalize(self, _release_binding, id(self), key, digest) + + def _payload(self) -> dict[str, object]: + """Return the complete value-minimized canonical evidence snapshot.""" + next_action = ( + "Do not apply the proposed Position lifecycle change." + if self.review_outcome_code == "rejected" + else ( + "Re-resolve tenant-qualified Position and Assignment truth at the requested business/system " + "coordinate; require authoritative actor separation, reviewed evidence, staffing safety, " + "and immutable audit/outbox before any lifecycle mutation." + ) + ) + return { + "assignment_snapshot_digest_sha256": self.assignment_snapshot_digest_sha256, + "current_status_code": self.current_status_code, + "decision_authority": "human_review_only", + "effective_on": self.effective_on.isoformat(), + "evidence_version": self.evidence_version, + "mutation_state": "not_authorized_to_apply", + "next_action": next_action, + "position_lifecycle_change_reference": str(self.position_lifecycle_change_reference), + "position_record_id": str(self.position_record_id), + "position_snapshot_digest_sha256": self.position_snapshot_digest_sha256, + "proposed_status_code": self.proposed_status_code, + "reason_code": self.reason_code, + "recorded_at": _utc_text(self.recorded_at), + "requester_actor_reference": self.requester_actor_reference, + "review_outcome_code": self.review_outcome_code, + "review_state": "human_reviewed", + "reviewed_at": _utc_text(self.reviewed_at), + "reviewer_actor_reference": self.reviewer_actor_reference, + "scope_verification_state": "requires_authoritative_resolution", + "tenant_record_id": str(self.tenant_record_id), + } + + def canonical_document(self) -> dict[str, object]: + """Return the same snapshot whose digest remains bound to issuance.""" + document = self._payload() + digest = sha256(_canonical_bytes(document)).hexdigest() + with _LOCK: + issued_digest = _ISSUANCE_DIGESTS.get(id(self)) + if issued_digest != digest: + raise ValueError("position lifecycle review evidence changed after issuance.") + return document + + def canonical_json(self) -> str: + """Return deterministic canonical JSON for immutable audit correlation.""" + return _canonical_bytes(self.canonical_document()).decode("utf-8") + + def content_digest(self) -> str: + """Return SHA-256 of the verified canonical evidence bytes.""" + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + def __repr__(self) -> str: + """Avoid leaking tenant, Position, actor, or evidence correlations in routine logs.""" + return "PositionLifecycleChangeReviewPacket(redacted)" diff --git a/packages/position-lifecycle-review/tests/test_artifact_execution.py b/packages/position-lifecycle-review/tests/test_artifact_execution.py new file mode 100644 index 000000000..cc5904848 --- /dev/null +++ b/packages/position-lifecycle-review/tests/test_artifact_execution.py @@ -0,0 +1,39 @@ +"""Regression contract for consolidated Position Lifecycle Review quality execution.""" + +from pathlib import Path + + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_FOUNDATION_WORKFLOW = _REPOSITORY_ROOT / ".github/workflows/foundation-ci.yml" +_FOUNDATION_HYGIENE = _REPOSITORY_ROOT / "tests/test_foundation_ci_dependency_hygiene.sh" +_ARTIFACT_CONTRACT = _REPOSITORY_ROOT / "tests/test_position_lifecycle_review_artifact.sh" +_RETIRED_LEAF_WORKFLOW = ( + _REPOSITORY_ROOT / ".github/workflows/position-lifecycle-review-quality.yml" +) + + +def test_quality_contract_is_owned_by_canonical_foundation() -> None: + """Keep Position Lifecycle Review artifact parity in the canonical Foundation lane.""" + workflow = _FOUNDATION_WORKFLOW.read_text(encoding="utf-8") + hygiene = _FOUNDATION_HYGIENE.read_text(encoding="utf-8") + artifact = _ARTIFACT_CONTRACT.read_text(encoding="utf-8") + + assert not _RETIRED_LEAF_WORKFLOW.exists() + assert "Prove Foundation CI dependency hygiene" in workflow + assert "test_position_lifecycle_review_artifact.sh" in hygiene + assert "PYTHONPATH=packages/position-lifecycle-review/src" not in artifact + assert "sys.version_info[:3] != (3, 14, 7)" in artifact + assert "--require-hashes --no-deps --only-binary=:all:" in artifact + assert 'wheel_sha="$(sha256sum "${wheel_path}" | awk \'{print $1}\')"' in artifact + assert '"${venv_dir}/bin/python" -m pytest' in artifact + assert '-c "${package_root}/pyproject.toml"' in artifact + + +def test_quality_contract_paths_ignore_process_cwd(monkeypatch) -> None: + """Repository-owned quality paths must not depend on the process cwd.""" + monkeypatch.chdir(Path(__file__).resolve().parents[1]) + + assert _FOUNDATION_WORKFLOW.is_file() + assert _FOUNDATION_HYGIENE.is_file() + assert _ARTIFACT_CONTRACT.is_file() + assert not _RETIRED_LEAF_WORKFLOW.exists() diff --git a/packages/position-lifecycle-review/tests/test_repository_truth.py b/packages/position-lifecycle-review/tests/test_repository_truth.py new file mode 100644 index 000000000..c0e1d06aa --- /dev/null +++ b/packages/position-lifecycle-review/tests/test_repository_truth.py @@ -0,0 +1,22 @@ +"""Repository-state truth contract for Position lifecycle review documentation.""" + +from pathlib import Path + + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_DOCUMENTS = ( + _REPOSITORY_ROOT / "docs/traceability/position-lifecycle-review.md", + _REPOSITORY_ROOT / "docs/adr/0111-governed-position-lifecycle-review.md", + _REPOSITORY_ROOT / "packages/position-lifecycle-review/README.md", +) + + +def test_lifecycle_review_docs_describe_effective_repository_controls() -> None: + """Buyer-facing docs must not mistake classic protection for effective rules.""" + for document_path in _DOCUMENTS: + document = document_path.read_text(encoding="utf-8") + assert "issue #89" in document.lower() + assert "effective organization ruleset" in document + assert "branch protection is currently absent" not in document + assert "Protected `develop`" not in document + assert "Protected Orgmetra" not in document diff --git a/packages/position-lifecycle-review/tests/test_review.py b/packages/position-lifecycle-review/tests/test_review.py new file mode 100644 index 000000000..7430ffc3a --- /dev/null +++ b/packages/position-lifecycle-review/tests/test_review.py @@ -0,0 +1,229 @@ +"""Adversarial contract tests for governed Position lifecycle review evidence.""" + +from dataclasses import replace +from datetime import date, datetime, timedelta, timezone +import gc +from uuid import UUID, uuid4 + +import pytest + +from orgmetra_position_lifecycle_review import PositionLifecycleChangeReviewPacket + +TENANT = UUID("0198a800-1111-7000-8000-000000000001") +POSITION = UUID("0198a800-2222-7000-8000-000000000002") +CHANGE = UUID("12345678-1234-4abc-8def-1234567890ab") +REQUESTER = "actor:11111111-1111-4111-8111-111111111111" +REVIEWER = "actor:22222222-2222-4222-8222-222222222222" +POSITION_DIGEST = "a" * 64 +ASSIGNMENT_DIGEST = "b" * 64 +REVIEWED_AT = datetime(2026, 8, 24, 11, 0, tzinfo=timezone.utc) +RECORDED_AT = datetime(2026, 8, 24, 11, 1, tzinfo=timezone.utc) + + +def build_packet(**overrides: object) -> PositionLifecycleChangeReviewPacket: + """Return one valid reviewed lifecycle-change packet with optional overrides.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "position_record_id": POSITION, + "position_lifecycle_change_reference": uuid4(), + "current_status_code": "active", + "proposed_status_code": "frozen", + "effective_on": date(2026, 9, 1), + "position_snapshot_digest_sha256": POSITION_DIGEST, + "assignment_snapshot_digest_sha256": ASSIGNMENT_DIGEST, + "requester_actor_reference": REQUESTER, + "reviewer_actor_reference": REVIEWER, + "reason_code": "temporary_freeze", + "review_outcome_code": "approved_for_authoritative_resolution", + "evidence_version": 1, + "reviewed_at": REVIEWED_AT, + "recorded_at": RECORDED_AT, + } + values.update(overrides) + return PositionLifecycleChangeReviewPacket(**values) # type: ignore[arg-type] + + +def test_approved_packet_is_deterministic_and_value_minimized() -> None: + """Canonical evidence contains governance metadata but no worker or HR payload.""" + packet = build_packet() + document = packet.canonical_document() + assert document["review_state"] == "human_reviewed" + assert document["scope_verification_state"] == "requires_authoritative_resolution" + assert document["mutation_state"] == "not_authorized_to_apply" + assert document["decision_authority"] == "human_review_only" + assert document["next_action"] == ( + "Re-resolve tenant-qualified Position and Assignment truth at the requested business/system " + "coordinate; require authoritative actor separation, reviewed evidence, staffing safety, " + "and immutable audit/outbox before any lifecycle mutation." + ) + encoded = packet.canonical_json() + assert packet.content_digest() == packet.content_digest() + assert "employee" not in encoded.lower() + assert "person" not in encoded.lower() + assert "compensation" not in encoded.lower() + assert str(POSITION) in encoded + assert "PositionLifecycleChangeReviewPacket(redacted)" == repr(packet) + + +def test_rejected_review_has_stop_next_action() -> None: + """A rejected review cannot be mistaken for mutation-ready evidence.""" + packet = build_packet(review_outcome_code="rejected") + assert packet.canonical_document()["next_action"] == "Do not apply the proposed Position lifecycle change." + + +@pytest.mark.parametrize( + ("current", "proposed", "reason"), + [ + ("open", "active", "position_reactivation"), + ("open", "frozen", "temporary_freeze"), + ("open", "closed", "position_closure"), + ("open", "abolished", "position_abolition"), + ("active", "frozen", "temporary_freeze"), + ("active", "closed", "position_closure"), + ("active", "abolished", "position_abolition"), + ("frozen", "open", "position_reactivation"), + ("frozen", "active", "position_reactivation"), + ("frozen", "closed", "position_closure"), + ("frozen", "abolished", "position_abolition"), + ("closed", "open", "position_reactivation"), + ("closed", "abolished", "position_abolition"), + ], +) +def test_reviewed_transition_vocabulary(current: str, proposed: str, reason: str) -> None: + """Reviewed transitions are explicit and do not silently invent a lifecycle state.""" + packet = build_packet(current_status_code=current, proposed_status_code=proposed, reason_code=reason) + assert packet.canonical_document()["proposed_status_code"] == proposed + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("current_status_code", "shadow"), + ("proposed_status_code", "shadow"), + ("reason_code", "free_text"), + ("review_outcome_code", "maybe"), + ("evidence_version", 2), + ], +) +def test_governed_vocabulary_fails_closed(field_name: str, value: object) -> None: + """Unknown governance vocabulary and schema versions are rejected.""" + with pytest.raises(ValueError): + build_packet(**{field_name: value}) + + +def test_noop_and_abolished_revival_are_rejected() -> None: + """No-op evidence and resurrection of an abolished Position are not reviewable here.""" + with pytest.raises(ValueError): + build_packet(proposed_status_code="active") + with pytest.raises(ValueError): + build_packet(current_status_code="abolished", proposed_status_code="open", reason_code="position_reactivation") + + +class ForgedStatus(str): + """String subclass that lies about equality/hash membership.""" + + def __hash__(self) -> int: + return hash("active") + + def __eq__(self, other: object) -> bool: + return other == "active" + + +def test_runtime_subclasses_cannot_forge_governance_text_or_dates() -> None: + """Trust-bearing scalar checks run only on exact built-in runtime types.""" + with pytest.raises(TypeError): + build_packet(current_status_code=ForgedStatus("shadow")) + + class DateSubclass(date): + pass + + with pytest.raises(TypeError): + build_packet(effective_on=DateSubclass(2026, 9, 1)) + + +def test_identifiers_and_actor_correlations_are_opaque_and_separated() -> None: + """Operational HRIS UUIDs and pseudonymous actor references obey distinct contracts.""" + with pytest.raises(ValueError): + build_packet(tenant_record_id=UUID(int=0)) + with pytest.raises(ValueError): + build_packet(position_record_id=UUID(int=(1 << 128) - 1)) + with pytest.raises(ValueError): + build_packet(position_lifecycle_change_reference=TENANT) + with pytest.raises(ValueError): + build_packet(requester_actor_reference="actor:alice@example.com") + with pytest.raises(ValueError): + build_packet(reviewer_actor_reference=REQUESTER) + + +def test_digests_are_exact_lowercase_sha256() -> None: + """Reviewed Position and Assignment snapshots use canonical SHA-256 text.""" + with pytest.raises(ValueError): + build_packet(position_snapshot_digest_sha256="A" * 64) + with pytest.raises(ValueError): + build_packet(assignment_snapshot_digest_sha256="b" * 63) + + +def test_review_and_recorded_time_are_exact_utc_and_monotonic() -> None: + """Human review precedes or equals system-recorded evidence time in canonical UTC.""" + with pytest.raises(ValueError): + build_packet(recorded_at=REVIEWED_AT - timedelta(seconds=1)) + with pytest.raises(ValueError): + build_packet(reviewed_at=REVIEWED_AT.astimezone(timezone(timedelta(hours=9)))) + with pytest.raises(ValueError): + build_packet(recorded_at=RECORDED_AT.replace(tzinfo=None)) + + +def test_post_construction_payload_tampering_fails_closed() -> None: + """Frozen-dataclass bypass cannot change checked canonical evidence.""" + packet = build_packet() + object.__setattr__(packet, "proposed_status_code", "closed") + with pytest.raises(ValueError): + packet.canonical_json() + + +def test_live_change_reference_cannot_bind_conflicting_evidence() -> None: + """One live tenant-qualified review reference cannot denote two reviewed truths.""" + packet = build_packet(position_lifecycle_change_reference=CHANGE) + with pytest.raises(ValueError): + replace(packet, proposed_status_code="closed", reason_code="position_closure") + duplicate = replace(packet) + assert duplicate.canonical_json() == packet.canonical_json() + + +def test_reference_binding_releases_only_after_all_duplicates_die() -> None: + """Live duplicate accounting keeps a correlation bound until the last packet is gone.""" + packet = build_packet(position_lifecycle_change_reference=CHANGE) + duplicate = replace(packet) + key_values = { + "tenant_record_id": packet.tenant_record_id, + "position_lifecycle_change_reference": packet.position_lifecycle_change_reference, + } + del duplicate + gc.collect() + with pytest.raises(ValueError): + build_packet(**key_values, proposed_status_code="closed", reason_code="position_closure") + del packet + gc.collect() + replacement = build_packet(**key_values, proposed_status_code="closed", reason_code="position_closure") + assert replacement.proposed_status_code == "closed" + + +def test_staffing_and_compensation_values_are_not_packet_fields() -> None: + """The review evidence deliberately carries no allocation or compensation value.""" + packet = build_packet() + assert not hasattr(packet, "allocation_ratio") + assert not hasattr(packet, "compensation_amount") + + +def test_release_binding_raises_on_digest_drift() -> None: + """Fail loudly if a binding's digest drifted from its issuance before release.""" + packet = build_packet() + import orgmetra_position_lifecycle_review.review as review_module + + key = (TENANT, uuid4()) + review_module._REFERENCE_BINDINGS[key] = ("0" * 64, 1) + try: + with pytest.raises(AssertionError, match="digest drifted"): + review_module._release_binding(id(packet), key, POSITION_DIGEST) + finally: + review_module._REFERENCE_BINDINGS.pop(key, None) diff --git a/packages/position-lifecycle-review/tests/test_validation_edges.py b/packages/position-lifecycle-review/tests/test_validation_edges.py new file mode 100644 index 000000000..6e67c78e2 --- /dev/null +++ b/packages/position-lifecycle-review/tests/test_validation_edges.py @@ -0,0 +1,79 @@ +"""Focused failure-boundary coverage for Position lifecycle review evidence.""" + +from datetime import date, datetime, timezone +from uuid import UUID, uuid4 + +import pytest + +from orgmetra_position_lifecycle_review import PositionLifecycleChangeReviewPacket + +TENANT = UUID("0198a800-1111-7000-8000-000000000001") +POSITION = UUID("0198a800-2222-7000-8000-000000000002") +REQUESTER = "actor:11111111-1111-4111-8111-111111111111" +REVIEWER = "actor:22222222-2222-4222-8222-222222222222" +NOW = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc) + + +def packet(**overrides: object) -> PositionLifecycleChangeReviewPacket: + """Build one valid packet while keeping each edge test independent.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "position_record_id": POSITION, + "position_lifecycle_change_reference": uuid4(), + "current_status_code": "active", + "proposed_status_code": "frozen", + "effective_on": date(2026, 9, 1), + "position_snapshot_digest_sha256": "a" * 64, + "assignment_snapshot_digest_sha256": "b" * 64, + "requester_actor_reference": REQUESTER, + "reviewer_actor_reference": REVIEWER, + "reason_code": "temporary_freeze", + "review_outcome_code": "approved_for_authoritative_resolution", + "evidence_version": 1, + "reviewed_at": NOW, + "recorded_at": NOW, + } + values.update(overrides) + return PositionLifecycleChangeReviewPacket(**values) # type: ignore[arg-type] + + +def test_non_uuid_and_non_uuid4_values_fail_closed() -> None: + """Short-circuit UUID type branches are exercised explicitly.""" + with pytest.raises(ValueError): + packet(tenant_record_id="0198a800-1111-7000-8000-000000000001") + with pytest.raises(ValueError): + packet(position_lifecycle_change_reference="12345678-1234-4abc-8def-1234567890ab") + with pytest.raises(ValueError): + packet(position_lifecycle_change_reference=TENANT) + + +def test_digest_and_actor_runtime_types_fail_before_parsing() -> None: + """Caller-defined/coercible scalar types cannot enter digest or actor parsing.""" + with pytest.raises(TypeError): + packet(position_snapshot_digest_sha256=123) + with pytest.raises(ValueError): + packet(requester_actor_reference=123) + with pytest.raises(ValueError): + packet(requester_actor_reference="not-an-actor-reference") + + +def test_actor_uuid_must_be_canonical_uuid4() -> None: + """Actor correlation accepts neither UUIDv7 nor noncanonical UUIDv4 text.""" + with pytest.raises(ValueError): + packet(requester_actor_reference="actor:0198a800-1111-7000-8000-000000000001") + with pytest.raises(ValueError): + packet(requester_actor_reference="actor:11111111-1111-4111-8111-11111111111A") + + +def test_reason_must_match_proposed_state() -> None: + """A valid reason token cannot be rebound to a semantically different target state.""" + with pytest.raises(ValueError): + packet(proposed_status_code="closed", reason_code="temporary_freeze") + + +def test_evidence_version_rejects_boolean_and_timestamp_rejects_non_datetime() -> None: + """Boolean/int coercion and date/datetime coercion are not accepted as evidence schema/time.""" + with pytest.raises(ValueError): + packet(evidence_version=True) + with pytest.raises(ValueError): + packet(reviewed_at=date(2026, 8, 24)) diff --git a/tests/test_foundation_ci_dependency_hygiene.sh b/tests/test_foundation_ci_dependency_hygiene.sh old mode 100644 new mode 100755 index 2c0f5087f..923291e80 --- a/tests/test_foundation_ci_dependency_hygiene.sh +++ b/tests/test_foundation_ci_dependency_hygiene.sh @@ -4,6 +4,7 @@ set -euo pipefail repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" workflow_path="${repository_root}/.github/workflows/foundation-ci.yml" requirements_path="${repository_root}/.github/requirements/foundation-test.txt" +position_lifecycle_contract="${repository_root}/tests/test_position_lifecycle_review_artifact.sh" expected_install="python -m pip install --require-hashes --no-deps --only-binary=:all: -r .github/requirements/foundation-test.txt" expected_default_pr_target=$' pull_request:\n branches:\n - develop\n' @@ -90,3 +91,10 @@ for package_name in coverage iniconfig packaging pluggy Pygments pytest pytest-c exit 1 fi done + +if [[ ! -f "${position_lifecycle_contract}" ]]; then + printf 'Foundation CI Position Lifecycle Review artifact contract is missing.\n' >&2 + exit 1 +fi + +bash "${position_lifecycle_contract}" diff --git a/tests/test_position_lifecycle_review_artifact.sh b/tests/test_position_lifecycle_review_artifact.sh new file mode 100755 index 000000000..1afd6a342 --- /dev/null +++ b/tests/test_position_lifecycle_review_artifact.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +package_root="${repository_root}/packages/position-lifecycle-review" +requirements_path="${repository_root}/.github/requirements/foundation-test.txt" +retired_workflow="${repository_root}/.github/workflows/position-lifecycle-review-quality.yml" +build_requirement="/tmp/orgmetra-position-lifecycle-review-build.txt" +build_tree="/tmp/orgmetra-position-lifecycle-review-build-tree" +dist_dir="/tmp/orgmetra-position-lifecycle-review-dist" +venv_dir="/tmp/orgmetra-position-lifecycle-review-venv" +install_requirement="/tmp/orgmetra-position-lifecycle-review-install.txt" + +if [[ -e "${retired_workflow}" ]]; then + printf 'Retired Position Lifecycle Review leaf workflow must not exist.\n' >&2 + exit 1 +fi + +python - <<'PY' +import sys + +if sys.version_info[:3] != (3, 14, 7): + raise SystemExit(f"Position Lifecycle Review quality requires CPython 3.14.7, got {sys.version}") +PY + +printf '%s\n' 'setuptools==84.0.0 --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670' > "${build_requirement}" +python -m pip install --require-hashes --no-deps --only-binary=:all: -r "${build_requirement}" + +rm -rf "${build_tree}" "${dist_dir}" "${venv_dir}" +cp -a "${package_root}" "${build_tree}" +mkdir -p "${dist_dir}" +python -m pip wheel --no-deps --no-build-isolation --wheel-dir "${dist_dir}" "${build_tree}" + +mapfile -t wheels < <(find "${dist_dir}" -maxdepth 1 -type f -name '*.whl' -print) +if [[ "${#wheels[@]}" -ne 1 ]]; then + printf 'Position Lifecycle Review build must produce exactly one wheel.\n' >&2 + exit 1 +fi +wheel_path="${wheels[0]}" +wheel_sha="$(sha256sum "${wheel_path}" | awk '{print $1}')" + +python -m venv "${venv_dir}" +"${venv_dir}/bin/python" -m pip install --require-hashes --no-deps --only-binary=:all: -r "${requirements_path}" +printf 'orgmetra-position-lifecycle-review[test] @ file://%s --hash=sha256:%s\n' "${wheel_path}" "${wheel_sha}" > "${install_requirement}" +"${venv_dir}/bin/python" -m pip install --require-hashes --no-deps -r "${install_requirement}" +"${venv_dir}/bin/python" -m pip check + +"${venv_dir}/bin/python" - <<'PY' +from importlib.metadata import metadata +from pathlib import Path + +import coverage +import pytest +import pytest_cov +import orgmetra_position_lifecycle_review + +venv_root = Path("/tmp/orgmetra-position-lifecycle-review-venv").resolve() +module_path = Path(orgmetra_position_lifecycle_review.__file__).resolve() +if not module_path.is_relative_to(venv_root): + raise SystemExit(f"package imported outside isolated environment: {module_path}") +for module in (coverage, pytest, pytest_cov): + dependency_path = Path(module.__file__).resolve() + if not dependency_path.is_relative_to(venv_root): + raise SystemExit(f"test dependency imported outside isolated environment: {dependency_path}") +if "test" not in (metadata("orgmetra-position-lifecycle-review").get_all("Provides-Extra") or []): + raise SystemExit("built distribution does not expose the reviewed test extra") +PY + +cd /tmp +COVERAGE_FILE=/tmp/orgmetra-position-lifecycle-review.coverage \ + "${venv_dir}/bin/python" -m pytest \ + -c "${package_root}/pyproject.toml" \ + "${package_root}/tests"