diff --git a/.github/workflows/foundation-ci.yml b/.github/workflows/foundation-ci.yml index 6b475d6f2..76373ce05 100644 --- a/.github/workflows/foundation-ci.yml +++ b/.github/workflows/foundation-ci.yml @@ -52,10 +52,54 @@ jobs: run: npm run validate - name: Prove Foundation CI dependency hygiene run: bash tests/test_foundation_ci_dependency_hygiene.sh + - name: Set up exact Job Qualification Rule 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 Job qualification rule review installed-artifact contract + run: | + printf '%s\n' 'setuptools==84.0.0 --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670' > /tmp/orgmetra-job-qualification-rule-review-build.txt + python -m pip install --require-hashes --no-deps --only-binary=:all: -r /tmp/orgmetra-job-qualification-rule-review-build.txt + rm -rf /tmp/orgmetra-job-qualification-rule-review-build-tree /tmp/orgmetra-job-qualification-rule-review-dist /tmp/orgmetra-job-qualification-rule-review-venv + cp -a packages/job-qualification-rule-review /tmp/orgmetra-job-qualification-rule-review-build-tree + mkdir -p /tmp/orgmetra-job-qualification-rule-review-dist + python -m pip wheel --no-deps --no-build-isolation --wheel-dir /tmp/orgmetra-job-qualification-rule-review-dist /tmp/orgmetra-job-qualification-rule-review-build-tree + test "$(find /tmp/orgmetra-job-qualification-rule-review-dist -maxdepth 1 -type f -name '*.whl' | wc -l)" -eq 1 + python -m venv /tmp/orgmetra-job-qualification-rule-review-venv + /tmp/orgmetra-job-qualification-rule-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-job-qualification-rule-review-dist -maxdepth 1 -type f -name '*.whl' -print -quit)" + wheel_sha="$(sha256sum "$wheel_path" | awk '{print $1}')" + printf 'orgmetra-job-qualification-rule-review[test] @ file://%s --hash=sha256:%s\n' "$wheel_path" "$wheel_sha" > /tmp/orgmetra-job-qualification-rule-review-install.txt + /tmp/orgmetra-job-qualification-rule-review-venv/bin/python -m pip install --require-hashes --no-deps -r /tmp/orgmetra-job-qualification-rule-review-install.txt + /tmp/orgmetra-job-qualification-rule-review-venv/bin/python -m pip check + /tmp/orgmetra-job-qualification-rule-review-venv/bin/python - <<'PY' + from importlib.metadata import metadata + from pathlib import Path + import coverage + import pytest + import pytest_cov + import orgmetra_job_qualification_rule_review + + venv_root = Path("/tmp/orgmetra-job-qualification-rule-review-venv").resolve() + module_path = Path(orgmetra_job_qualification_rule_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-job-qualification-rule-review").get_all("Provides-Extra") or []): + raise SystemExit("built distribution does not expose the reviewed test extra") + PY + cd /tmp + COVERAGE_FILE=/tmp/orgmetra-job-qualification-rule-review.coverage /tmp/orgmetra-job-qualification-rule-review-venv/bin/python -m pytest -c "$GITHUB_WORKSPACE/packages/job-qualification-rule-review/pyproject.toml" "$GITHUB_WORKSPACE/packages/job-qualification-rule-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/0104-governed-job-qualification-rule-review.md b/docs/adr/0104-governed-job-qualification-rule-review.md new file mode 100644 index 000000000..e1932c766 --- /dev/null +++ b/docs/adr/0104-governed-job-qualification-rule-review.md @@ -0,0 +1,33 @@ +# ADR 0104: Governed Job qualification-rule review + +- Status: proposed on active PR; not protected-main truth until merged. +- Date: 2026-08-24 + +## Context + +Protected `develop` models Job Analysis versions, Tasks, FJA and KSAO evidence, and the PRD requires qualification rules, but there is no bounded governance object proving which job-analysis evidence supported a proposed qualification rule before that rule can influence recruiting or selection. + +OPM describes job analysis as the systematic linkage of job tasks and competencies/KSAs and states that job-analysis information supports recruitment, qualification, assessment and selection. OPM also emphasizes evidence for task/KSA importance and current job requirements. The Uniform Guidelines on Employee Selection Procedures provide a federal framework for the proper use and documentation of selection procedures. Orgmetra uses these public sources as design evidence only; this package does not decide legal compliance, reproduce licensed standards, or validate a selection procedure. + +## Decision + +Create an Orgmetra-owned `JobQualificationRuleReviewPacket` as PII-minimized, human-reviewed proposal evidence. + +The packet binds tenant and authoritative Job scope, one Job Analysis snapshot reference/digest, one opaque qualification-rule artifact reference/digest, Task/KSAO/source linkage digests, a controlled rule category, a business-effective date, distinct requester/reviewer correlations, a controlled review reason, bounded evidence version, human review time, and an Orgmetra-generated system-recorded UTC issuance time. + +The controlled category describes only the type of job requirement evidence (`credential_requirement`, `education_training_requirement`, `experience_requirement`, `knowledge_skill_ability_requirement`, or `task_or_work_requirement`). It does not record candidate qualification, cut scores, assessment outcomes, legal status, or an autonomous eligibility decision. + +Every packet is fixed to human review and `not_authorized_for_candidate_or_employment_decision`. Before a reviewed rule changes authoritative Job/Job Analysis truth or is used in recruiting/selection, the host must re-resolve exact tenant/Job/snapshot/artifact/linkage evidence and reviewer authority at the relevant business-time coordinate and atomically preserve immutable audit/outbox evidence. + +## Privacy and integrity consequences + +Canonical evidence excludes Person/candidate identifiers and PII, compensation, assessment scores, raw rule or qualification text, credentials, prompts/model output, and free-form review text. Operational HRIS references use the core non-sentinel UUID contract; packet-owned artifact and actor correlations require opaque UUIDv4 references. + +Trust-bearing primitives are exact built-in types. Malformed/noncanonical IDs or SHA-256, unreviewed categories/reasons, actor overlap, invalid evidence versions, caller-supplied system time, future review time, mutable fixed governance, and post-issuance mutation fail closed. The process-local issuance registry is defense in depth only; durable identity, uniqueness, authorization, and audit remain host responsibilities. + +## Alternatives rejected + +- **Store free-form minimum-qualification text in the governance packet.** Rejected because it expands durable sensitive/business text without improving provenance; the reviewed artifact is referenced and hashed instead. +- **Evaluate candidates inside this packet.** Rejected because job-rule governance and candidate evidence/selection decisions are distinct high-impact boundaries. +- **Infer rules directly from an LLM.** Rejected because model output is untrusted draft evidence and cannot substitute for Job Analysis linkage or accountable human review. +- **Treat a qualification category as proof of validity or legality.** Rejected because jurisdiction, job relatedness, validity and applicant application require separate authoritative review. diff --git a/docs/doctoring/job-qualification-rule-review-references.md b/docs/doctoring/job-qualification-rule-review-references.md new file mode 100644 index 000000000..804c6d6f8 --- /dev/null +++ b/docs/doctoring/job-qualification-rule-review-references.md @@ -0,0 +1,29 @@ +# Job qualification-rule review references + +Reviewed: 2026-08-24. + +## Authoritative public sources + +U.S. Office of Personnel Management. (n.d.). *Job analysis*. https://www.opm.gov/frequently-asked-questions/assessment-policy-faq/job-analysis/ + +- OPM describes job analysis as a systematic procedure for identifying work tasks and the competencies/KSAs directly related to performance. +- OPM states that job-analysis information supports recruitment, assessment, selection, performance management, and career development, and that evidence should establish the importance of tasks and KSAs. + +U.S. Office of Personnel Management. (n.d.). *General Schedule qualification policies*. https://www.opm.gov/policy-data-oversight/classification-qualifications/general-schedule-qualification-policies/ + +- OPM distinguishes job-analysis evidence from the ways applicants may demonstrate KSAs, including qualifying experience, education, or training. +- Orgmetra uses that distinction only to keep Job qualification-rule provenance separate from candidate evaluation; it does not adopt federal qualification standards as a universal employer policy. + +Equal Employment Opportunity Commission, Department of Justice, Department of Labor, & Civil Service Commission. (1978). *Uniform Guidelines on Employee Selection Procedures*, 29 C.F.R. pt. 1607. https://www.ecfr.gov/current/title-29/subtitle-B/chapter-XIV/part-1607 + +- The eCFR current-title URL is the authoritative continuously updated regulatory publication target. Automated access was rate-limited during the 2026-08-24 review, so the regulation text was cross-checked against the official GovInfo annual CFR publication at https://www.govinfo.gov/content/pkg/CFR-2023-title29-vol4/pdf/CFR-2023-title29-vol4-part1607.pdf rather than treating the older annual edition as current law. +- The Guidelines provide a federal framework for proper use, validity evidence, and documentation of employee selection procedures. +- This package records review provenance only. It makes no finding that a qualification rule is legally required, job-related, validated, nondiscriminatory, or appropriate for a particular jurisdiction or applicant. + +## Repository sources of truth + +ContextualWisdomLab. (2026). *Orgmetra product requirements document*. `docs/PRD.md` on protected `develop`. + +ContextualWisdomLab. (2026). *Orgmetra Job Analysis kernel*. `packages/hris-kernel/src/orgmetra_hris_kernel/job_analysis.py` on protected `develop`. + +The protected-main sources remain authoritative for Job/Job Analysis ownership. This active PR adds review evidence only and does not create a second source of Job truth or a candidate-screening engine. diff --git a/docs/traceability/job-qualification-rule-review.md b/docs/traceability/job-qualification-rule-review.md new file mode 100644 index 000000000..a88505804 --- /dev/null +++ b/docs/traceability/job-qualification-rule-review.md @@ -0,0 +1,29 @@ +# Job qualification-rule review traceability + +## State boundary + +- **Protected-parent adoption snapshot:** `develop@eb9757f8649aaad026a9865508d9aad50c1a7a4f` includes protected #161's repository-quality consolidation and remains the parent adopted by this Draft lane. The protected branch must be re-read again before merge; this snapshot is evidence of the adoption point, not a perpetual claim about the latest protected head. +- **Active PR truth:** this Draft branch proposes `JobQualificationRuleReviewPacket`; it is not protected-main truth until normal protected integration completes. +- **Foreign dependencies:** none are written or queried directly. This slice is Orgmetra-owned and standalone. + +## Requirement-to-evidence matrix + +| Requirement | Production boundary | Regression / gate | +|---|---|---| +| Tie proposed qualification rules to authoritative Job Analysis | Job + Job Analysis snapshot reference/digest | scope-ID and digest regressions | +| Preserve Task/KSAO/source provenance | independent Task, KSAO and source SHA-256 digests | malformed digest regressions | +| Avoid free-form sensitive rule storage | opaque qualification-rule artifact reference + digest and closed rule category | canonical privacy assertions | +| Keep job-rule design separate from candidate evaluation | fixed `not_authorized_for_candidate_or_employment_decision` | fixed-governance direct-construction tests | +| Preserve authoritative identifier ownership | tenant/Job/snapshot use non-sentinel operational UUIDs; packet-owned artifact/actors use UUIDv4 | namespace/sentinel/version regressions | +| Preserve business, review and system-recorded time separately | exact `effective_on`, exact UTC `reviewed_at`, owner-generated UTC `recorded_at` | type/timezone/future-review/caller-injection regressions | +| Require accountable human review | distinct requester/reviewer UUIDv4 correlations and mandatory review state | actor-overlap and governance regressions | +| Version high-impact evidence | bounded exact integer `evidence_version` | bool/non-int/range and digest-change regressions | +| Detect in-process post-issuance mutation | process-local weak issuance digest outside packet slots; export revalidates one snapshot | valid-field and hostile-runtime mutation regressions | +| Preserve exact package/artifact evidence | canonical Foundation CI builds and SHA-256-binds the isolated wheel after leaf-workflow retirement | Foundation installed-artifact contract + leaf-reintroduction regression | +| Preserve exact owned test sufficiency | package pytest config requires statement and branch coverage 100% | `--cov-fail-under=100`, branch coverage enabled | + +## Authoritative next action + +Before a reviewed rule may alter Job/Job Analysis truth or influence applicant screening or selection, the Orgmetra host must re-resolve the exact tenant, Job, Job Analysis snapshot, qualification-rule artifact, Task/KSAO/source evidence, reviewer identity and authority, and current business-time context. Any authoritative change must preserve human confirmation and immutable audit/outbox evidence atomically. + +The packet does not decide whether an applicant satisfies a rule, whether a rule is lawful or validated, or whether any employment action should occur. LLM output remains untrusted draft evidence only. diff --git a/manifest.json b/manifest.json index f7b6cf55e..57118ca57 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": "2e7e89e7c1158b23e4cf5947ab2a6440c3a3dc47f4a6b23182ff5a15e53b5eaf", + "bytes": 10324, + "lines": 169 }, { "path": ".gitignore", @@ -472,4 +472,4 @@ "lines": 637 } ] -} +} \ No newline at end of file diff --git a/packages/job-qualification-rule-review/CHANGELOG.md b/packages/job-qualification-rule-review/CHANGELOG.md new file mode 100644 index 000000000..e3a3c09b3 --- /dev/null +++ b/packages/job-qualification-rule-review/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.1.0 - 2026-08-24 + +- Add governed Job qualification-rule review evidence linked to authoritative Job Analysis. +- Bind Task, KSAO, source, snapshot, and qualification-rule artifact provenance by SHA-256 without copying candidate PII or raw rule text. +- Keep rule proposals explicitly non-authoritative for applicant screening, candidate rejection, and employment decisions. +- Require distinct requester/reviewer correlations, controlled rule categories/reasons, explicit evidence versioning, business-effective time, human-review time, and owner-generated system-recorded time. +- Add deterministic canonical JSON/SHA-256 correlation, redacted representations, process-local post-issuance tamper detection, hostile runtime-type regressions, hash-bound isolated-wheel execution, and exact 100% owned statement/branch coverage. diff --git a/packages/job-qualification-rule-review/README.md b/packages/job-qualification-rule-review/README.md new file mode 100644 index 000000000..ccc4c526b --- /dev/null +++ b/packages/job-qualification-rule-review/README.md @@ -0,0 +1,30 @@ +# Orgmetra Job qualification-rule review + +This package creates **human-reviewed, evidence-linked Job qualification-rule proposals**. It does not screen a candidate, reject an applicant, assign a qualification outcome, or mutate authoritative Job or Job Analysis state. + +A packet binds one tenant and Job to an exact Job Analysis snapshot, an opaque reviewed qualification-rule artifact, explicit Task/KSAO/source evidence digests, one controlled rule category, a business-effective date, distinct requester/reviewer correlations, evidence version, human review time, and an Orgmetra-generated system-recorded issuance time. + +The canonical evidence deliberately excludes candidate/person identifiers, names, email addresses, phone numbers, salary, assessment scores, cut scores, raw qualification/rule text, prompts, model output, credentials, and free-form review text. The reviewed artifact content remains outside this evidence envelope and is correlated by opaque reference plus SHA-256 digest. + +## Controlled categories + +- `credential_requirement` +- `education_training_requirement` +- `experience_requirement` +- `knowledge_skill_ability_requirement` +- `task_or_work_requirement` + +These categories classify the *kind of reviewed job requirement evidence*. They do not establish that a particular qualification standard is lawful, necessary, validated, or satisfied by any person. + +## Safety boundary + +Every packet remains fixed to: + +- purpose `job_qualification_rule_review`; +- review state `reviewed_for_authoritative_resolution`; +- decision authority `not_authorized_for_candidate_or_employment_decision`; +- mandatory human review. + +Before any qualification rule affects recruiting or selection, the Orgmetra host must re-resolve tenant, Job, Job Analysis snapshot, exact artifact and linkage digests, accountable reviewer identity/authority, and the current business-time context. It must then persist authoritative changes with immutable audit/outbox evidence. Candidate evaluation is a separate governed boundary. + +System-recorded time is generated by this package rather than supplied by callers. Trust-bearing scalar values require exact runtime primitives. Canonical exports revalidate a single payload snapshot and compare it with an issuance digest held outside writable packet slots so post-issuance mutation fails closed. This process-local seal is defense in depth, not a durable signature or authorization mechanism. diff --git a/packages/job-qualification-rule-review/pyproject.toml b/packages/job-qualification-rule-review/pyproject.toml new file mode 100644 index 000000000..44ce41587 --- /dev/null +++ b/packages/job-qualification-rule-review/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "orgmetra-job-qualification-rule-review" +version = "0.1.0" +description = "Governed Job qualification-rule review evidence for Orgmetra." +requires-python = ">=3.12" + +[project.optional-dependencies] +test = ["pytest>=8.3", "pytest-cov>=5.0"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = [ + "--cov=orgmetra_job_qualification_rule_review", + "--cov-branch", + "--cov-report=term-missing", + "--cov-fail-under=100", +] diff --git a/packages/job-qualification-rule-review/src/orgmetra_job_qualification_rule_review/__init__.py b/packages/job-qualification-rule-review/src/orgmetra_job_qualification_rule_review/__init__.py new file mode 100644 index 000000000..a54edce8a --- /dev/null +++ b/packages/job-qualification-rule-review/src/orgmetra_job_qualification_rule_review/__init__.py @@ -0,0 +1,11 @@ +"""Public contract for governed Job qualification-rule review evidence.""" + +from .review import ( + JobQualificationRuleReviewPacket, + build_job_qualification_rule_review_packet, +) + +__all__ = [ + "JobQualificationRuleReviewPacket", + "build_job_qualification_rule_review_packet", +] diff --git a/packages/job-qualification-rule-review/src/orgmetra_job_qualification_rule_review/review.py b/packages/job-qualification-rule-review/src/orgmetra_job_qualification_rule_review/review.py new file mode 100644 index 000000000..6351914b4 --- /dev/null +++ b/packages/job-qualification-rule-review/src/orgmetra_job_qualification_rule_review/review.py @@ -0,0 +1,369 @@ +"""Governed, PII-minimized Job qualification-rule review evidence. + +This module records a human-reviewed proposal tying one qualification-rule artifact to +an authoritative Job Analysis snapshot and its Task/KSAO/source provenance. It never +evaluates a candidate, rejects an applicant, mutates Job truth, or authorizes an +employment decision. Authoritative use must re-resolve the reviewed evidence and +human authority before persisting any rule or applying it to a person. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime, timezone +from hashlib import sha256 +import json +import re +from threading import RLock +from typing import ClassVar +from uuid import UUID +from weakref import WeakKeyDictionary + +_DIGEST_PATTERN = re.compile(r"^[0-9a-f]{64}$") +_MAX_UUID_INT = (1 << 128) - 1 +_MAX_EVIDENCE_VERSION = 2_147_483_647 +_PURPOSE_CODE = "job_qualification_rule_review" +_REVIEW_STATE = "reviewed_for_authoritative_resolution" +_DECISION_AUTHORITY = "not_authorized_for_candidate_or_employment_decision" +_ALLOWED_RULE_CATEGORIES = frozenset( + { + "credential_requirement", + "education_training_requirement", + "experience_requirement", + "knowledge_skill_ability_requirement", + "task_or_work_requirement", + } +) +_ALLOWED_REASON_CODES = frozenset( + { + "new_job_analysis", + "job_analysis_revision", + "periodic_job_analysis_review", + "source_evidence_change", + } +) +_NEXT_ACTION = ( + "Within tenant_record_id, re-resolve the authoritative Job and Job Analysis snapshot, " + "verify the exact qualification-rule artifact and Task/KSAO/source evidence digests, " + "confirm accountable reviewer identity and authority at the business-effective coordinate, " + "then persist any authoritative Job-rule change with immutable audit/outbox evidence. " + "Candidate evaluation and employment decisions require separate governed human review." +) + + +def _require_exact_text(value: object, field_name: str) -> str: + """Return trust-bearing text only when it is an exact built-in string.""" + if type(value) is not str: + raise ValueError(f"{field_name} must be an exact string") + return value + + +def _validate_operational_uuid_text(value: object, field_name: str) -> str: + """Require canonical non-sentinel UUID text without imposing one UUID version.""" + text = _require_exact_text(value, field_name) + try: + parsed = UUID(text) + except ValueError as exc: + raise ValueError(f"{field_name} must be canonical operational UUID text") from exc + if str(parsed) != text or parsed.int in (0, _MAX_UUID_INT): + raise ValueError(f"{field_name} must be canonical operational UUID text") + return text + + +def _validate_reference( + value: object, + prefix: str, + field_name: str, + *, + require_uuid4: bool, +) -> str: + """Require a namespaced canonical UUID reference owned by the stated boundary.""" + text = _require_exact_text(value, field_name) + namespace = f"{prefix}:" + if len(text) > 160 or not text.startswith(namespace): + raise ValueError(f"{field_name} must be an opaque {prefix}: reference") + suffix = text[len(namespace) :] + try: + parsed = UUID(suffix) + except ValueError as exc: + raise ValueError( + f"{field_name} must be an opaque {prefix}: reference" + ) from exc + if str(parsed) != suffix or parsed.int in (0, _MAX_UUID_INT): + raise ValueError( + f"{field_name} must be an opaque {prefix}: reference" + ) + if require_uuid4 and parsed.version != 4: + raise ValueError(f"{field_name} must use an opaque canonical UUIDv4 reference") + return text + + +def _validate_digest(value: object, field_name: str) -> str: + """Require exact lower-case SHA-256 hexadecimal evidence.""" + text = _require_exact_text(value, field_name) + if not _DIGEST_PATTERN.fullmatch(text): + raise ValueError(f"{field_name} must be lower-case SHA-256 hex") + return text + + +def _validate_rule_category(value: object) -> str: + """Require one reviewed non-sensitive qualification-rule category.""" + text = _require_exact_text(value, "rule_category") + if text not in _ALLOWED_RULE_CATEGORIES: + raise ValueError("rule_category must use a reviewed Job qualification category") + return text + + +def _validate_reason_code(value: object) -> str: + """Require one reviewed non-sensitive Job-analysis review reason.""" + text = _require_exact_text(value, "reason_code") + if text not in _ALLOWED_REASON_CODES: + raise ValueError("reason_code must use a reviewed Job-analysis reason") + return text + + +def _validate_effective_date(value: object) -> date: + """Require an exact built-in date for business-effective rule semantics.""" + if type(value) is not date: + raise ValueError("effective_on must be an exact built-in date") + return value + + +def _validate_utc_timestamp(value: object, field_name: str) -> datetime: + """Require an exact built-in datetime whose timezone is the UTC singleton.""" + if type(value) is not datetime or value.tzinfo is not timezone.utc: + raise ValueError(f"{field_name} must be an exact built-in UTC datetime") + return value + + +def _validate_evidence_version(value: object) -> int: + """Require one bounded positive exact integer evidence version.""" + if type(value) is not int or not 1 <= value <= _MAX_EVIDENCE_VERSION: + raise ValueError("evidence_version must be an exact integer from 1 through 2147483647") + return value + + +def _canonical_timestamp(value: datetime) -> str: + """Render a previously validated UTC datetime as deterministic RFC 3339 text.""" + return value.isoformat().replace("+00:00", "Z") + + +def _canonical_json(payload: dict[str, object]) -> str: + """Serialize one evidence payload deterministically for audit correlation.""" + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +@dataclass(frozen=True, slots=True, weakref_slot=True, eq=False, repr=False) +class JobQualificationRuleReviewPacket: + """Human-reviewed Job qualification-rule proposal without candidate-decision authority. + + The packet is deliberately non-subclassable: trust-boundary validation must + never be bypassable by an in-process subclass overriding the validation hook. + """ + + tenant_record_id: str + job_record_reference: str + job_analysis_snapshot_reference: str + job_analysis_snapshot_digest: str + qualification_rule_artifact_reference: str + qualification_rule_artifact_digest: str + task_linkage_digest: str + ksao_linkage_digest: str + source_evidence_digest: str + rule_category: str + effective_on: date + requester_actor_reference: str + reviewer_actor_reference: str + reason_code: str + evidence_version: int + reviewed_at: datetime + recorded_at: datetime = field(init=False) + purpose_code: str = _PURPOSE_CODE + review_state: str = _REVIEW_STATE + decision_authority: str = _DECISION_AUTHORITY + human_review_required: bool = True + next_action: str = _NEXT_ACTION + + _issuance_digests: ClassVar[ + WeakKeyDictionary["JobQualificationRuleReviewPacket", str] + ] = WeakKeyDictionary() + _issuance_lock: ClassVar[RLock] = RLock() + + def __init_subclass__(cls, **kwargs: object) -> None: + """Reject every subclass so validation can never be overridden away.""" + raise TypeError( + "JobQualificationRuleReviewPacket must not be subclassed; " + "trust-boundary validation is non-overridable" + ) + + def __post_init__(self) -> None: + """Generate system time, validate all trust-bearing fields, and seal evidence.""" + object.__setattr__(self, "recorded_at", datetime.now(timezone.utc)) + payload = self._validated_payload() + digest = sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + with self._issuance_lock: + self._issuance_digests[self] = digest + + def __repr__(self) -> str: + """Return a fully redacted representation safe for routine logs and assertions.""" + return "JobQualificationRuleReviewPacket()" + + def _validated_payload(self) -> dict[str, object]: + """Revalidate and snapshot every canonical evidence field exactly once.""" + tenant_record_id = _validate_operational_uuid_text( + self.tenant_record_id, "tenant_record_id" + ) + job_record_reference = _validate_reference( + self.job_record_reference, + "job_record", + "job_record_reference", + require_uuid4=False, + ) + job_analysis_snapshot_reference = _validate_reference( + self.job_analysis_snapshot_reference, + "job_analysis_snapshot", + "job_analysis_snapshot_reference", + require_uuid4=False, + ) + job_analysis_snapshot_digest = _validate_digest( + self.job_analysis_snapshot_digest, "job_analysis_snapshot_digest" + ) + qualification_rule_artifact_reference = _validate_reference( + self.qualification_rule_artifact_reference, + "qualification_rule_artifact", + "qualification_rule_artifact_reference", + require_uuid4=True, + ) + qualification_rule_artifact_digest = _validate_digest( + self.qualification_rule_artifact_digest, + "qualification_rule_artifact_digest", + ) + task_linkage_digest = _validate_digest(self.task_linkage_digest, "task_linkage_digest") + ksao_linkage_digest = _validate_digest(self.ksao_linkage_digest, "ksao_linkage_digest") + source_evidence_digest = _validate_digest( + self.source_evidence_digest, "source_evidence_digest" + ) + rule_category = _validate_rule_category(self.rule_category) + effective_on = _validate_effective_date(self.effective_on) + requester_actor_reference = _validate_reference( + self.requester_actor_reference, + "actor", + "requester_actor_reference", + require_uuid4=True, + ) + reviewer_actor_reference = _validate_reference( + self.reviewer_actor_reference, + "actor", + "reviewer_actor_reference", + require_uuid4=True, + ) + if requester_actor_reference == reviewer_actor_reference: + raise ValueError("requester and reviewer must be different actor references") + reason_code = _validate_reason_code(self.reason_code) + evidence_version = _validate_evidence_version(self.evidence_version) + reviewed_at = _validate_utc_timestamp(self.reviewed_at, "reviewed_at") + recorded_at = _validate_utc_timestamp(self.recorded_at, "recorded_at") + if recorded_at < reviewed_at: + raise ValueError("recorded_at cannot precede reviewed_at") + if type(self.purpose_code) is not str or self.purpose_code != _PURPOSE_CODE: + raise ValueError("purpose_code must remain job_qualification_rule_review") + if type(self.review_state) is not str or self.review_state != _REVIEW_STATE: + raise ValueError("review_state must remain reviewed_for_authoritative_resolution") + if ( + type(self.decision_authority) is not str + or self.decision_authority != _DECISION_AUTHORITY + ): + raise ValueError( + "decision_authority must remain not_authorized_for_candidate_or_employment_decision" + ) + if self.human_review_required is not True: + raise ValueError("human review is mandatory for Job qualification-rule evidence") + if type(self.next_action) is not str or self.next_action != _NEXT_ACTION: + raise ValueError("next_action must remain the governed qualification-rule instruction") + + return { + "decision_authority": self.decision_authority, + "effective_on": effective_on.isoformat(), + "evidence_version": evidence_version, + "human_review_required": self.human_review_required, + "job_analysis_snapshot_digest": job_analysis_snapshot_digest, + "job_analysis_snapshot_reference": job_analysis_snapshot_reference, + "job_record_reference": job_record_reference, + "ksao_linkage_digest": ksao_linkage_digest, + "next_action": self.next_action, + "purpose_code": self.purpose_code, + "qualification_rule_artifact_digest": qualification_rule_artifact_digest, + "qualification_rule_artifact_reference": qualification_rule_artifact_reference, + "reason_code": reason_code, + "recorded_at": _canonical_timestamp(recorded_at), + "requester_actor_reference": requester_actor_reference, + "review_state": self.review_state, + "reviewed_at": _canonical_timestamp(reviewed_at), + "reviewer_actor_reference": reviewer_actor_reference, + "rule_category": rule_category, + "source_evidence_digest": source_evidence_digest, + "task_linkage_digest": task_linkage_digest, + "tenant_record_id": tenant_record_id, + } + + def _verified_payload(self) -> dict[str, object]: + """Return one verified snapshot or fail closed after any post-issuance mutation.""" + payload = self._validated_payload() + digest = sha256(_canonical_json(payload).encode("utf-8")).hexdigest() + with self._issuance_lock: + issued_digest = self._issuance_digests.get(self) + if issued_digest != digest: + raise ValueError("Job qualification-rule evidence was modified after issuance") + return payload + + def canonical_document(self) -> dict[str, object]: + """Return a detached verified canonical evidence document for audit persistence.""" + return dict(self._verified_payload()) + + def canonical_json(self) -> str: + """Return deterministic JSON from the exact verified evidence snapshot.""" + return _canonical_json(self._verified_payload()) + + def sha256_digest(self) -> str: + """Return SHA-256 over the exact verified canonical JSON evidence.""" + return sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +def build_job_qualification_rule_review_packet( + *, + tenant_record_id: str, + job_record_reference: str, + job_analysis_snapshot_reference: str, + job_analysis_snapshot_digest: str, + qualification_rule_artifact_reference: str, + qualification_rule_artifact_digest: str, + task_linkage_digest: str, + ksao_linkage_digest: str, + source_evidence_digest: str, + rule_category: str, + effective_on: date, + requester_actor_reference: str, + reviewer_actor_reference: str, + reason_code: str, + evidence_version: int, + reviewed_at: datetime, +) -> JobQualificationRuleReviewPacket: + """Build one human-reviewed Job qualification-rule proposal without decision authority.""" + return JobQualificationRuleReviewPacket( + tenant_record_id=tenant_record_id, + job_record_reference=job_record_reference, + job_analysis_snapshot_reference=job_analysis_snapshot_reference, + job_analysis_snapshot_digest=job_analysis_snapshot_digest, + qualification_rule_artifact_reference=qualification_rule_artifact_reference, + qualification_rule_artifact_digest=qualification_rule_artifact_digest, + task_linkage_digest=task_linkage_digest, + ksao_linkage_digest=ksao_linkage_digest, + source_evidence_digest=source_evidence_digest, + rule_category=rule_category, + effective_on=effective_on, + requester_actor_reference=requester_actor_reference, + reviewer_actor_reference=reviewer_actor_reference, + reason_code=reason_code, + evidence_version=evidence_version, + reviewed_at=reviewed_at, + ) diff --git a/packages/job-qualification-rule-review/tests/test_artifact_execution.py b/packages/job-qualification-rule-review/tests/test_artifact_execution.py new file mode 100644 index 000000000..74fcfedf6 --- /dev/null +++ b/packages/job-qualification-rule-review/tests/test_artifact_execution.py @@ -0,0 +1,35 @@ +"""Regression contract for consolidated exact installed-wheel quality execution.""" + +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/job-qualification-rule-review-quality.yml" +) +_VENV_PATH = "/tmp/orgmetra-job-qualification-rule-review-venv" + + +def test_foundation_executes_the_hash_bound_installed_wheel() -> None: + """Keep artifact parity inside the canonical one-job Foundation lane.""" + workflow = _FOUNDATION_WORKFLOW.read_text(encoding="utf-8") + + assert not _RETIRED_LEAF_WORKFLOW.exists() + assert "Set up exact Job Qualification Rule Review Python" in workflow + assert 'python-version: "3.14.7"' in workflow + assert "Run Job qualification rule review installed-artifact contract" in workflow + assert "PYTHONPATH=packages/job-qualification-rule-review/src" not in workflow + assert f"python -m venv {_VENV_PATH}" in workflow + assert ( + f'{_VENV_PATH}/bin/python -m pip install --require-hashes --no-deps ' + f'--only-binary=:all: -r "$GITHUB_WORKSPACE/.github/requirements/foundation-test.txt"' + in workflow + ) + assert 'wheel_sha="$(sha256sum "$wheel_path" | awk \'{print $1}\')"' in workflow + assert "for module in (coverage, pytest, pytest_cov):" in workflow + assert ( + f"{_VENV_PATH}/bin/python -m pytest " + '-c "$GITHUB_WORKSPACE/packages/job-qualification-rule-review/pyproject.toml"' + in workflow + ) diff --git a/packages/job-qualification-rule-review/tests/test_review.py b/packages/job-qualification-rule-review/tests/test_review.py new file mode 100644 index 000000000..bbc189e1b --- /dev/null +++ b/packages/job-qualification-rule-review/tests/test_review.py @@ -0,0 +1,382 @@ +"""Executable contract for governed Job qualification-rule review evidence.""" + +from dataclasses import replace +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from uuid import uuid4 + +import pytest + +from orgmetra_job_qualification_rule_review import ( + JobQualificationRuleReviewPacket, + build_job_qualification_rule_review_packet, +) + + +DIGEST_A = "a" * 64 +DIGEST_B = "b" * 64 +DIGEST_C = "c" * 64 +DIGEST_D = "d" * 64 +TENANT_ID = "0198f1c0-7d6e-7f10-8a41-b1d9e2fe0199" +JOB_ID = str(uuid4()) +SNAPSHOT_ID = str(uuid4()) +RULE_ARTIFACT_ID = str(uuid4()) +REQUESTER_ID = str(uuid4()) +REVIEWER_ID = str(uuid4()) +REVIEWED_AT = datetime(2026, 8, 23, 20, 0, tzinfo=timezone.utc) + + +def values() -> dict[str, object]: + """Return one complete valid packet input for focused tests.""" + return { + "tenant_record_id": TENANT_ID, + "job_record_reference": f"job_record:{JOB_ID}", + "job_analysis_snapshot_reference": f"job_analysis_snapshot:{SNAPSHOT_ID}", + "job_analysis_snapshot_digest": DIGEST_A, + "qualification_rule_artifact_reference": f"qualification_rule_artifact:{RULE_ARTIFACT_ID}", + "qualification_rule_artifact_digest": DIGEST_B, + "task_linkage_digest": DIGEST_C, + "ksao_linkage_digest": DIGEST_D, + "source_evidence_digest": "e" * 64, + "rule_category": "knowledge_skill_ability_requirement", + "effective_on": date(2026, 9, 1), + "requester_actor_reference": f"actor:{REQUESTER_ID}", + "reviewer_actor_reference": f"actor:{REVIEWER_ID}", + "reason_code": "new_job_analysis", + "evidence_version": 1, + "reviewed_at": REVIEWED_AT, + } + + +def test_builds_human_reviewed_non_authoritative_qualification_rule_evidence() -> None: + """A valid packet binds reviewed job evidence without granting decision authority.""" + packet = build_job_qualification_rule_review_packet(**values()) + + assert isinstance(packet, JobQualificationRuleReviewPacket) + assert packet.purpose_code == "job_qualification_rule_review" + assert packet.review_state == "reviewed_for_authoritative_resolution" + assert packet.decision_authority == "not_authorized_for_candidate_or_employment_decision" + assert packet.human_review_required is True + assert packet.recorded_at >= packet.reviewed_at + assert packet.recorded_at.tzinfo is timezone.utc + assert "authoritative" in packet.next_action + assert "audit/outbox" in packet.next_action + assert repr(packet) == "JobQualificationRuleReviewPacket()" + + document = packet.canonical_document() + assert document["rule_category"] == "knowledge_skill_ability_requirement" + assert document["effective_on"] == "2026-09-01" + assert document["reviewed_at"] == "2026-08-23T20:00:00Z" + assert document["recorded_at"].endswith("Z") + assert len(packet.sha256_digest()) == 64 + + +def test_canonical_evidence_excludes_candidate_pii_rule_text_and_decision_values() -> None: + """Durable evidence must remain minimized to correlations, categories, and digests.""" + encoded = JobQualificationRuleReviewPacket(**values()).canonical_json() + + for forbidden in ( + "candidate_profile", + "person_record", + "name", + "email", + "phone", + "salary", + "assessment_score", + "cut_score", + "rule_text", + "qualification_text", + "prompt", + "model_output", + ): + assert forbidden not in encoded + + +@pytest.mark.parametrize( + "field,value", + [ + ("rule_category", "candidate_quality"), + ("rule_category", "license:CPA"), + ("reason_code", "manager_preference"), + ("reason_code", "candidate_failed"), + ], +) +def test_rejects_unreviewed_rule_categories_and_reasons(field: str, value: object) -> None: + """Qualification metadata must stay inside reviewed non-sensitive vocabularies.""" + data = values() + data[field] = value + with pytest.raises(ValueError): + JobQualificationRuleReviewPacket(**data) + + +def test_accepts_each_reviewed_rule_category_and_reason() -> None: + """Every published controlled category and reason remains executable.""" + categories = ( + "credential_requirement", + "education_training_requirement", + "experience_requirement", + "knowledge_skill_ability_requirement", + "task_or_work_requirement", + ) + reasons = ( + "new_job_analysis", + "job_analysis_revision", + "periodic_job_analysis_review", + "source_evidence_change", + ) + for category in categories: + data = values() + data["rule_category"] = category + assert JobQualificationRuleReviewPacket(**data).rule_category == category + for reason in reasons: + data = values() + data["reason_code"] = reason + assert JobQualificationRuleReviewPacket(**data).reason_code == reason + + +def test_rejects_requester_reviewer_overlap() -> None: + """The accountable human reviewer must not be the requesting actor correlation.""" + data = values() + data["reviewer_actor_reference"] = data["requester_actor_reference"] + with pytest.raises(ValueError, match="different actor"): + JobQualificationRuleReviewPacket(**data) + + +@pytest.mark.parametrize( + "field,value", + [ + ("tenant_record_id", "00000000-0000-0000-0000-000000000000"), + ("tenant_record_id", "ffffffff-ffff-ffff-ffff-ffffffffffff"), + ("tenant_record_id", "not-a-uuid"), + ("job_record_reference", f"position_record:{JOB_ID}"), + ("job_record_reference", "job_record:not-a-uuid"), + ("job_record_reference", "job_record:00000000-0000-0000-0000-000000000000"), + ("job_analysis_snapshot_reference", f"job_analysis:{SNAPSHOT_ID}"), + ("qualification_rule_artifact_reference", f"qualification_rule:{RULE_ARTIFACT_ID}"), + ( + "qualification_rule_artifact_reference", + "qualification_rule_artifact:6ba7b810-9dad-11d1-80b4-00c04fd430c8", + ), + ], +) +def test_rejects_invalid_scope_and_evidence_references(field: str, value: object) -> None: + """Authoritative scope and packet-owned evidence references must stay opaque and canonical.""" + data = values() + data[field] = value + with pytest.raises(ValueError): + JobQualificationRuleReviewPacket(**data) + + +@pytest.mark.parametrize( + "field", + [ + "qualification_rule_artifact_reference", + "requester_actor_reference", + "reviewer_actor_reference", + ], +) +def test_packet_owned_references_require_uuid4(field: str) -> None: + """Packet-owned correlations reject UUIDv1 timestamp/node metadata.""" + data = values() + prefix = str(data[field]).split(":", 1)[0] + data[field] = f"{prefix}:6ba7b810-9dad-11d1-80b4-00c04fd430c8" + with pytest.raises(ValueError, match="UUIDv4"): + JobQualificationRuleReviewPacket(**data) + + +@pytest.mark.parametrize( + "field,value", + [ + ("job_analysis_snapshot_digest", "A" * 64), + ("qualification_rule_artifact_digest", "abc"), + ("task_linkage_digest", "g" * 64), + ("ksao_linkage_digest", 7), + ("source_evidence_digest", "f" * 63), + ], +) +def test_rejects_noncanonical_sha256_evidence(field: str, value: object) -> None: + """Every provenance digest must be exact lower-case SHA-256 text.""" + data = values() + data[field] = value + with pytest.raises(ValueError): + JobQualificationRuleReviewPacket(**data) + + +class ForgedText(str): + """Hostile string subtype that attempts to forge equality and hashing.""" + + def __eq__(self, other: object) -> bool: + """Pretend to equal any compared governance value.""" + return True + + def __hash__(self) -> int: + """Return a forged stable hash.""" + return 0 + + +@pytest.mark.parametrize( + "field", + [ + "tenant_record_id", + "job_record_reference", + "job_analysis_snapshot_reference", + "qualification_rule_artifact_reference", + "job_analysis_snapshot_digest", + "task_linkage_digest", + "rule_category", + "requester_actor_reference", + "reason_code", + ], +) +def test_rejects_hostile_string_subclasses_before_governance_checks(field: str) -> None: + """Trust-bearing text cannot execute caller-defined equality, hash, or parser behavior.""" + data = values() + data[field] = ForgedText(str(data[field])) + with pytest.raises(ValueError): + JobQualificationRuleReviewPacket(**data) + + +class ForgedDate(date): + """Hostile date subtype that must not enter the business-time boundary.""" + + +def test_effective_date_requires_exact_builtin_date() -> None: + """Business-effective time rejects datetime and caller-defined date behavior.""" + for value in ( + datetime(2026, 9, 1, tzinfo=timezone.utc), + ForgedDate(2026, 9, 1), + "2026-09-01", + ): + data = values() + data["effective_on"] = value + with pytest.raises(ValueError, match="effective_on"): + JobQualificationRuleReviewPacket(**data) + + +class ForgedDateTime(datetime): + """Hostile datetime subtype that must not enter accountable review time.""" + + +def test_review_time_requires_exact_builtin_utc_datetime() -> None: + """Human-review evidence rejects naive, offset, and caller-defined datetimes.""" + candidates = ( + datetime(2026, 8, 23, 20, 0), + datetime(2026, 8, 24, 5, 0, tzinfo=timezone(timedelta(hours=9))), + ForgedDateTime(2026, 8, 23, 20, 0, tzinfo=timezone.utc), + ) + for value in candidates: + data = values() + data["reviewed_at"] = value + with pytest.raises(ValueError, match="reviewed_at"): + JobQualificationRuleReviewPacket(**data) + + +def test_future_human_review_cannot_precede_owner_generated_system_time() -> None: + """A caller cannot claim a human review that occurs after owner-generated issuance.""" + data = values() + data["reviewed_at"] = datetime.now(timezone.utc) + timedelta(days=1) + with pytest.raises(ValueError, match="recorded_at"): + JobQualificationRuleReviewPacket(**data) + + +@pytest.mark.parametrize("value", [True, 0, -1, 2_147_483_648, "1", Decimal("1")]) +def test_evidence_version_requires_bounded_exact_integer(value: object) -> None: + """High-impact evidence versioning rejects bool, non-int, nonpositive, and overflow values.""" + data = values() + data["evidence_version"] = value + with pytest.raises(ValueError, match="evidence_version"): + JobQualificationRuleReviewPacket(**data) + + +def test_higher_valid_evidence_version_changes_canonical_evidence() -> None: + """A reviewed evidence revision must be explicit in canonical audit correlation.""" + first = JobQualificationRuleReviewPacket(**values()) + data = values() + data["evidence_version"] = 2 + second = JobQualificationRuleReviewPacket(**data) + assert second.canonical_document()["evidence_version"] == 2 + assert first.sha256_digest() != second.sha256_digest() + + +@pytest.mark.parametrize( + "field,value", + [ + ("purpose_code", "candidate_screening"), + ("review_state", "auto_approved"), + ("decision_authority", "authorized_to_reject_candidate"), + ("human_review_required", False), + ("next_action", "reject candidate now"), + ], +) +def test_direct_construction_cannot_weaken_fixed_governance(field: str, value: object) -> None: + """Direct construction cannot convert review evidence into employment-decision authority.""" + data = values() + data[field] = value + with pytest.raises(ValueError): + JobQualificationRuleReviewPacket(**data) + + +def test_recorded_at_is_owner_generated_and_not_a_public_constructor_input() -> None: + """Callers cannot inject or backdate system-recorded issuance time.""" + data = values() + data["recorded_at"] = REVIEWED_AT + with pytest.raises(TypeError): + JobQualificationRuleReviewPacket(**data) + + +def test_post_issuance_valid_field_mutation_invalidates_all_exports() -> None: + """Even a syntactically valid field rewrite cannot create a second canonical truth.""" + packet = JobQualificationRuleReviewPacket(**values()) + object.__setattr__(packet, "rule_category", "experience_requirement") + + with pytest.raises(ValueError, match="modified after issuance"): + packet.canonical_document() + with pytest.raises(ValueError, match="modified after issuance"): + packet.canonical_json() + with pytest.raises(ValueError, match="modified after issuance"): + packet.sha256_digest() + + +def test_post_issuance_hostile_runtime_mutation_fails_before_serialization() -> None: + """Export revalidation rejects hostile runtime types before calling their text behavior.""" + packet = JobQualificationRuleReviewPacket(**values()) + object.__setattr__(packet, "rule_category", ForgedText("experience_requirement")) + with pytest.raises(ValueError, match="exact string"): + packet.canonical_json() + + +def test_replace_remains_non_authoritative_and_reissues_system_time() -> None: + """A copied proposal is independently issued and never inherits employment authority.""" + first = JobQualificationRuleReviewPacket(**values()) + second = replace(first, rule_category="experience_requirement") + + assert second.decision_authority == "not_authorized_for_candidate_or_employment_decision" + assert second.recorded_at >= first.recorded_at + assert second.sha256_digest() != first.sha256_digest() + + +def test_subclass_cannot_override_the_trust_boundary() -> None: + """Subclass forgery must fail closed at class definition, before any instance.""" + + def bypass_validation(_self: object) -> dict[str, object]: + return {} + + with pytest.raises(TypeError, match="must not be subclassed"): + type( + "ForgedPacket", + (JobQualificationRuleReviewPacket,), + {"_validated_payload": bypass_validation}, + ) + + +def test_valid_packets_still_canonicalize_and_hash_deterministically() -> None: + """Base-class construction keeps stable canonical evidence per issued instance.""" + first = build_job_qualification_rule_review_packet(**values()) + second_values = values() + second_values["rule_category"] = "experience_requirement" + second = build_job_qualification_rule_review_packet(**second_values) + + assert first.canonical_json() == first.canonical_json() + assert first.sha256_digest() == first.sha256_digest() + assert second.sha256_digest() != first.sha256_digest() + assert len(first.canonical_json()) > 0