diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f4752d7..c78a3f0fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to Orgmetra will be documented in this file. - Active-PR `orgmetra_selection_review` packet for PII-minimized, evidence-bound human selection review: canonical operational tenant identity, UUID-backed opaque candidate/Job/sealed-evidence/reviewer references, explicit purpose/reason/evidence version, deterministic canonical JSON and SHA-256 correlation, mandatory human decision state, redacted packet repr, and provenance-paired model evidence that remains `untrusted_draft`, with exact 100% owned statement and branch coverage required by its quality gate. - Active performance-criterion scope hardening: `criterion_observation_scope_guard` rejects criterion outcomes for a Job the worker did not effectively hold at the observation date, observations before the relevant assignment, and observations outside the referenced performance cycle while preserving valid multiple-assignment cases and existing bitemporal correction semantics. The guard evaluates current-recorded facts, derives the date coordinate from `observed_at` in UTC so session `TimeZone` cannot alter the result, uses a trusted function search path, and adds no PII or automated employment decision authority. The Foundation PostgreSQL contract also rejects a closed `recorded_to` on each time-coordinate lookup and proves UTC midnight plus non-UTC session `TimeZone` boundaries. - Bitemporal tenant-scoped organization hierarchy validation that rejects visible indirect parent cycles and reuses single-valued recorded-time reconstruction before graph traversal. +- Active-PR workforce composition evidence now freezes caller-owned knowledge cutoffs to exact UTC instants, detaches status-count containers before digesting, and resolves one cutoff before both change endpoints so mutable timezone providers cannot rewrite aggregate evidence. - Stacked governed job-analysis evidence contract via `JobAnalysisSnapshot`, `TaskEvidence`, `KSAORequirement`, `TaskKSAOLink`, `FunctionalJobAnalysisProfile`, and `EvidenceSource`: tenant/Job-scoped observable tasks, explicit Task-to-KSAO linkage, importance/difficulty/proficiency ratings, source/version/retrieval/SHA-256 provenance, deterministic canonical snapshot bytes, current O*NET evidence support, and historical DOT Data/People/Things compatibility. Validated snapshots require accountable human review and complete non-LLM evidence; LLM-origin material remains `analysis_draft`, and the snapshot is evidence input rather than a hiring, promotion, termination, compensation, or other high-impact employment decision. - Stacked governed audit/outbox slice via `AuditOutboxEvent`, `audit_event_record`, `outbox_delivery_record`, and `outbox_delivery_escalation_record`: CloudEvents 1.0-compatible PII-minimized metadata, exact canonical JSON bytes, database-verified SHA-256 digests, mandatory human confirmation for high-impact events, immutable audit evidence, tenant RLS, atomic audit/outbox insertion, guarded pending/leased/delivered/dead-lettered delivery state, tenant-safe `claim_outbox_delivery(...)` with deterministic due-work ordering, `FOR UPDATE ... SKIP LOCKED`, opaque worker identity, bounded future leases, immutable envelope return, and atomic takeover of genuinely expired leases only while retry attempts remain; owner-bound `complete_outbox_delivery(...)` and `retry_outbox_delivery(...)`; database-budget-governed `dead_letter_outbox_delivery(...)`; and a separately privileged `operator_dead_letter_expired_outbox_delivery(...)` recovery path for an exhausted final lease whose recorded worker identity is permanently unavailable. `maximum_attempt_count` is persisted on the delivery row, defaults to 5, is constrained to 1 through 100, and cannot be lowered by a dispatcher during finalization. Migration 0007 prevents retry or expired-lease takeover from creating attempt N+1; migration 0008 adds TRUNCATE guards, trusted function search paths, a concurrently built due-work partial index, session-independent immutable envelope validation, and operator recovery backed by separate NOLOGIN/NOBYPASSRLS owner/capability roles so the externally assignable operator role can invoke recovery without receiving direct transport-table read/write rights. Migration 0008 also rejects pre-existing reserved recovery-role names before project DDL, atomically contains the temporary schema-creation privilege used for function ownership handoff, and forces deferred escalation binding while the narrow SECURITY DEFINER owner is still active. Exponential/backoff policy selection, policy-specific producer configuration, and external delivery receipts remain subsequent work. - `orgmetra_hris_kernel` 0.4.0 with exclusive-versus-concurrent employment, staffable position coverage, exclusive-seat capacity, and `validate_assignment_write` at 100% statement and branch coverage. @@ -53,9 +54,11 @@ All notable changes to Orgmetra will be documented in this file. - Made assignment coverage status-aware: `active` and `leave` remain staffable while `terminated` and other non-eligible employment statuses fail closed. - Made organization hierarchy reconstruction fail closed on a cycle at the requested tenant, effective day, and knowledge cutoff while ignoring future-recorded and foreign-tenant facts. - Build the outbox due-work index concurrently during migration 0008, requiring that index step to run outside an explicit transaction block so established queues do not block writers while the index is built; pre-index hardening and post-index privileged role setup use separate explicit transactions. +- Workforce allocation validation now requires exact finite Decimal ratios in `(0, 1.0000]` with at most four fractional places before exact employment-portfolio or Position-seat aggregation, keeping the kernel aligned with persisted allocation scale and bounded arithmetic. ### Security +- Workforce canonical JSON and digest export now revalidate the full temporal, tenant, status, count, staffing, and reconciliation contract immediately before serialization, so post-construction low-level mutation cannot mint contradictory aggregate evidence. - Predictive-validity cases fail closed when selection evidence, Job scope, study criterion, converted worker, or system-recorded visibility does not match; the normalized case relation is tenant-qualified, append-only, TRUNCATE-protected, and forced through row-level security. - Purpose-bound PII authorization now fails closed across active tenant, authenticated actor tenant, resource tenant, resource kind, purpose, operation, operation-specific Keyverse scope, and requested-field subset; malformed/wildcard-like attributes, mutable field/scope collections, reserved UUID sentinels, and cross-tenant confused-deputy contexts are rejected before protected values are returned. Authorization requests and allow/deny evidence now also require and preserve one namespaced opaque target-resource reference, so immutable audit correlation identifies the exact HR record without copying its protected values. Authorization evidence otherwise contains governance metadata and field names only, with stable denial reasons and actionable next steps rather than PII. - LLM output constrained to draft evidence. @@ -73,4 +76,4 @@ All notable changes to Orgmetra will be documented in this file. ### Notes -- Protected `develop` at `e7ddb7a78a5e1460410005d10f43ebf18c5e12e4` includes normalized validity-study and criterion integrity, bitemporal workforce composition, governed candidate-to-worker conversion, purpose-bound PII authorization, GET-only People reads, governed People mutation/idempotency API, and the accepted ADR 0001–0003 source expansion integrated by #37. Job Analysis persistence/API and the selection-review packet remain active-PR truth until their unchanged exact heads satisfy fresh gates and merge. +- Protected `develop` at `9e3e4847510e1e612b48474ba42b177b8ed824df` includes the merged workforce-composition baseline from #33. Same-cutoff workforce change evidence and the export/allocation hardening in #54 remain active-PR truth until the unchanged exact head satisfies fresh required gates and independent review; predecessor PR or workflow evidence does not transfer. diff --git a/docs/adr/0011-bitemporal-workforce-composition.md b/docs/adr/0011-bitemporal-workforce-composition.md index 7e9302870..1794eaf1d 100644 --- a/docs/adr/0011-bitemporal-workforce-composition.md +++ b/docs/adr/0011-bitemporal-workforce-composition.md @@ -2,7 +2,7 @@ ## Status -Accepted on active PR #33 only. This document is not protected-`develop` product truth until the owning PR integrates. +Accepted on protected `develop` through merged PR #33. Active PR #54 strengthens the accepted contract's deterministic arithmetic and canonical-evidence integrity; those strengthening changes are not protected-`develop` truth until #54 integrates. ## Context @@ -19,9 +19,16 @@ Orgmetra will expose a pure `WorkforceCompositionSnapshot` derived from authorit - Employment count preserves the number of visible reportable employment relationships. - Before aggregation, the snapshot reuses the HRIS employment-concurrency invariant at the report coordinate. Two overlapping `exclusive` employments or an unknown concurrency code fail closed instead of being normalized into plausible headcount. - Staffed assignment count and staffed FTE are computed from visible assignments after reusing the existing assignment-to-employment coverage, per-employment allocation, and position-seat capacity integrity rules. +- Direct aggregate construction repeats the resulting staffing relationships: zero assignments require zero FTE and zero assigned people, staffed assignments require positive FTE and reportable employment/person totals, assigned people cannot exceed assignments, and staffed FTE cannot exceed the assignment count. - One overfilled Position seat therefore remains a data-integrity failure even when the aggregate FTE total itself looks plausible. - Unassigned-person count surfaces a buyer-actionable staffing gap without serializing row-level worker identity. - Status counts are aggregate employment evidence, sorted deterministically. +- The snapshot freezes the timezone-aware knowledge cutoff to an exact UTC datetime and detaches status-count containers before validation, so mutable caller objects cannot change canonical evidence after construction. +- Staffed FTE evidence is accepted only as an exact finite non-negative `Decimal` with at most four fractional places and is canonicalized to exactly four fractional places at construction; canonical export requires that fixed scale again. Equivalent values such as `0.5` and `0.5000` therefore serialize and hash identically, while extreme-scale direct evidence cannot reach comparison arithmetic. +- Canonical JSON and digest export re-run the same non-mutating temporal, tenant, status, count, staffing, and reconciliation invariants used after construction, so low-level runtime mutation cannot silently mint contradictory workforce evidence. +- Endpoint FTE totals and workforce-change deltas align finite Decimal coefficients before arithmetic, so the caller's ambient Decimal precision cannot change endpoint evidence, the reported delta, canonical JSON, or content digest. +- Employment-portfolio and Position-seat limits use the same exact Decimal coefficient total, so low caller precision cannot admit an overallocated staffing total. +- Allocation rows must be exact finite `Decimal` values in `(0, 1.0000]` with no more than four fractional places before exact coefficient aggregation. This keeps the kernel aligned with the People/API persistence scale contract and prevents hostile or accidental extreme exponents from creating unbounded integer-coefficient work. - Two visible versions of one Employment or Assignment identity fail closed. Invalid assignment coverage or over-allocation remains a data-integrity error rather than becoming a plausible metric. - The canonical JSON contains the opaque tenant identifier, report coordinates, aggregate metrics, and schema version only. It excludes person, employment, assignment, and position identifiers and all human-readable PII. - SHA-256 addresses the exact canonical UTF-8 representation so a caller can correlate a report with immutable audit evidence without copying source rows. @@ -46,7 +53,7 @@ Orgmetra will expose a pure `WorkforceCompositionSnapshot` derived from authorit ## Verification -`packages/hris-kernel/tests/test_workforce_composition.py`, `packages/hris-kernel/tests/test_workforce_composition_boundaries.py`, and `packages/hris-kernel/tests/test_workforce_position_capacity.py` require tenant isolation, concurrent-employment person deduplication, active/leave composition, terminated exclusion, future-effective and late-recorded exclusion, FTE and unassigned-person reporting, deterministic canonical evidence, historical recorded-time reconstruction, duplicate-version rejection, overlapping-exclusive-employment rejection, position-seat over-allocation rejection, assignment-person integrity, per-employment allocation-integrity reuse, and timezone-aware knowledge cutoffs. `.github/workflows/workforce-intelligence-quality.yml` checks out the exact candidate SHA and runs the complete HRIS kernel with the package's 100% statement and branch coverage threshold. +`packages/hris-kernel/tests/test_workforce_composition.py`, `packages/hris-kernel/tests/test_workforce_composition_boundaries.py`, `packages/hris-kernel/tests/test_workforce_position_capacity.py`, `packages/hris-kernel/tests/test_workforce_composition_change.py`, `packages/hris-kernel/tests/test_assignment_portfolio.py`, `packages/hris-kernel/tests/test_position_coverage.py`, `packages/hris-kernel/tests/test_allocation_and_snapshot_export_regressions.py`, and `packages/hris-kernel/tests/test_workforce_canonical_evidence_hardening.py` require tenant isolation, concurrent-employment person deduplication, active/leave composition, terminated exclusion, future-effective and late-recorded exclusion, FTE and unassigned-person reporting, deterministic canonical evidence, fixed four-decimal FTE canonicalization, extreme-scale FTE rejection, post-construction export revalidation including scale mutation, context-independent endpoint and workforce-change FTE arithmetic, historical recorded-time reconstruction, duplicate-version rejection, overlapping-exclusive-employment rejection, position-seat over-allocation rejection, low-precision allocation-limit rejection, allocation type/finiteness/scale rejection, assignment-person integrity, per-employment allocation-integrity reuse, and timezone-aware knowledge cutoffs. `.github/workflows/workforce-intelligence-quality.yml` checks out the exact candidate SHA and runs the complete HRIS kernel with the package's 100% statement and branch coverage threshold. ## References diff --git a/docs/adr/0024-same-cutoff-workforce-composition-change.md b/docs/adr/0024-same-cutoff-workforce-composition-change.md new file mode 100644 index 000000000..50e870517 --- /dev/null +++ b/docs/adr/0024-same-cutoff-workforce-composition-change.md @@ -0,0 +1,38 @@ +# ADR-0024: Workforce composition change uses one recorded-time cutoff + +**Status:** Proposed on active PR +**Decision owner:** Orgmetra + +## Context + +Protected Orgmetra can reconstruct a `WorkforceCompositionSnapshot` for one tenant at an effective business date and an explicit recorded-time knowledge cutoff. Buyers also need to compare workforce composition across two effective dates. A naive comparison can be misleading if the opening snapshot is reconstructed with an earlier knowledge cutoff than the closing snapshot: later corrections then appear indistinguishable from actual business-time workforce movement. + +ISO 30414:2025 is the current published second edition of the human-capital reporting and disclosure standard. Its public catalogue lists workforce composition, mobility and succession planning, and workforce turnover among core reporting areas. This ADR uses that public scope only; it does not reproduce licensed metric definitions or claim ISO certification. + +## Decision + +Orgmetra adds a pure `WorkforceCompositionChangeSnapshot` and builder in the HRIS kernel. + +- Both endpoint snapshots must belong to the same authoritative tenant. +- The opening effective date must be strictly earlier than the closing effective date. +- Both endpoint snapshots must use one exact `known_at` recorded-time cutoff. Effective-time change is therefore compared while knowledge time is held constant. +- The builder resolves that cutoff once to a detached UTC datetime before constructing either endpoint; caller-owned timezone providers cannot cause the two endpoint reconstructions to observe different recorded times. +- Each endpoint is built through the existing workforce-composition function, so contradictory bitemporal facts, invalid assignment coverage, impossible employment concurrency, over-allocation, and overfilled Position capacity continue to fail closed before aggregation. +- The comparison exposes net changes in distinct-person headcount, reportable employment count, staffed assignment count, staffed FTE, unassigned-person count, and deterministic per-status counts. +- The contract deliberately does **not** label a net change as a hire, separation, transfer, turnover event, cause, forecast, protected-attribute effect, or recommendation. Those claims require event-specific governed evidence that this aggregate comparison does not possess. +- Canonical JSON embeds only the two aggregate endpoint snapshots, their SHA-256 digests, aggregate deltas, the opaque tenant identifier, and schema version. It does not serialize row-level Person, Employment, Assignment, or Position identifiers. +- The result is descriptive workforce-intelligence evidence only and cannot authorize a high-impact employment action. + +## Consequences + +Buyers can compare two business dates without silently mixing later-recorded corrections into the change metric. The comparison is deterministic and audit-correlatable while remaining aggregate-only. Because it is intentionally not a turnover calculator, a later turnover/mobility slice must bind authoritative employment-transition evidence and its denominator/period policy explicitly rather than deriving causal labels from endpoint subtraction. + +The slice adds no persistence table, dashboard, export endpoint, forecasting model, diversity inference, or automated decision authority. Authorization and presentation remain at their owning boundaries. + +## Verification + +`packages/hris-kernel/tests/test_workforce_composition_change.py` covers realistic effective-date change, exact Decimal FTE deltas, deterministic source-order independence, aggregate-only canonical evidence, same-tenant enforcement, strictly forward effective dates, and one shared knowledge cutoff. `.github/workflows/workforce-intelligence-quality.yml` runs the complete HRIS kernel at exact 100% owned production statement and branch coverage. + +## References + +APA 7 references and current public standard metadata are recorded in `docs/doctoring/workforce-composition-change-references.md`. diff --git a/docs/doctoring/workforce-composition-change-references.md b/docs/doctoring/workforce-composition-change-references.md new file mode 100644 index 000000000..6bc2feab0 --- /dev/null +++ b/docs/doctoring/workforce-composition-change-references.md @@ -0,0 +1,17 @@ +# Workforce composition change references + +## Status + +Evidence for the active workforce-composition-change PR. This file does not make the active-PR code protected-`develop` truth before merge. + +## APA 7 references + +International Organization for Standardization. (2025). *ISO 30414:2025 Human resource management — Requirements and recommendations for human capital reporting and disclosure* (2nd ed.). ISO. https://www.iso.org/standard/30414 + +International Organization for Standardization. (2022). *ISO 30400:2022 Human resource management — Vocabulary* (2nd ed.). ISO. https://www.iso.org/standard/78044.html + +## Exact public evidence used + +The ISO catalogue was rechecked on August 20, 2026. ISO 30414:2025 remains the published second edition and lists workforce composition, mobility and succession planning, and workforce turnover among its core human-capital reporting areas; ISO 30414:2018 is withdrawn. ISO 30400:2022 remains the published second-edition HR-management vocabulary standard. + +Orgmetra uses only those public catalogue facts as design traceability. The implementation does not reproduce licensed ISO metric definitions, infer a turnover formula from the standard, or claim certification. The same-cutoff bitemporal comparison semantics are independently specified by ADR-0024 and executable Orgmetra tests. diff --git a/docs/traceability/workforce-composition-change.md b/docs/traceability/workforce-composition-change.md new file mode 100644 index 000000000..cf2b137b1 --- /dev/null +++ b/docs/traceability/workforce-composition-change.md @@ -0,0 +1,19 @@ +# Workforce composition change traceability + +| Requirement | Orgmetra evidence | Verification | Maturity | +|---|---|---|---| +| Compare business-time workforce states without knowledge-time drift | `WorkforceCompositionChangeSnapshot` requires identical endpoint `known_at` values, rechecks them immediately before canonical export, and the builder freezes one cutoff before both endpoints | different-cutoff, sequenced-timezone-provider, and post-construction cutoff-mutation regressions | implemented_on_active_pr | +| Preserve tenant isolation | endpoint tenants must match at construction and immediately before canonical export; builder supplies one tenant to both existing snapshots | cross-tenant direct-construction rejection plus post-construction tenant-mutation export rejection | implemented_on_active_pr | +| Require a real forward comparison | opening `effective_on` must be earlier than closing `effective_on` at construction and immediately before canonical export | equal-date rejection, post-construction date-order mutation rejection, and buyer-readable next action | implemented_on_active_pr | +| Require exact validated endpoint evidence | both endpoints must remain exact `WorkforceCompositionSnapshot` runtime values; subclasses and low-level endpoint replacement cannot bypass the canonical endpoint contract | forged-subclass construction regressions plus post-construction endpoint-type mutation rejection | implemented_on_active_pr | +| Reuse authoritative HRIS integrity | both endpoints call `build_workforce_composition_snapshot(...)`; endpoint `canonical_json()` rechecks its own canonical workforce invariants during comparison export | existing complete HRIS-kernel workforce/integrity suite plus change and mutation regressions | implemented_on_active_pr | +| Keep workforce intelligence descriptive | only net aggregate deltas are exposed; no hire/separation/turnover/cause/recommendation label exists | public API and canonical-schema review | implemented_on_active_pr | +| Preserve exact FTE arithmetic | staffed FTE change uses exact Decimal coefficient arithmetic over canonical four-decimal endpoints | realistic `1.0000` delta, caller Decimal-context parity, and canonical endpoint-FTE regressions | implemented_on_active_pr | +| Avoid row-level shadow HR data | canonical comparison embeds aggregate endpoint JSON/digests only | canonical output excludes `person_record_id` and all endpoint row identities by construction | implemented_on_active_pr | +| Deterministic audit correlation | canonical JSON + SHA-256 content digest are emitted only after cross-endpoint and endpoint-level invariant revalidation | reordered-source equality plus post-construction tenant/date/cutoff/type mutation fail-closed regressions | implemented_on_active_pr | +| Current standards traceability | ADR-0024 + doctoring record for ISO 30414:2025 and ISO 30400:2022 public metadata | official ISO catalogue rechecked August 20, 2026 | implemented_on_active_pr | +| Exact owned production coverage | `Workforce Intelligence Quality` runs complete HRIS kernel | package pytest-cov requires 100% statement and branch coverage | implemented_on_active_pr | + +## Buyer interpretation + +A positive or negative endpoint delta says only that aggregate composition differs between two effective dates when reconstructed with the same system-knowledge cutoff. It is not evidence that a particular person was hired, separated, transferred, promoted, retained, or caused the change. Event-specific mobility and turnover claims require separately governed employment-transition evidence and an explicit denominator/period policy. diff --git a/docs/traceability/workforce-composition.md b/docs/traceability/workforce-composition.md index c33c5ca0d..fd11fbf03 100644 --- a/docs/traceability/workforce-composition.md +++ b/docs/traceability/workforce-composition.md @@ -2,19 +2,25 @@ ## Status -Active-PR only. This evidence does not describe protected-`develop` product truth until PR #33 integrates. +The baseline workforce-composition contract is protected-`develop` truth through merged PR #33. Active PR #54 strengthens deterministic arithmetic and canonical-evidence integrity; those strengthening rows remain active-PR truth until #54 integrates. | Requirement | Decision / contract | Production implementation | Executable evidence | |---|---|---|---| -| Reconstruct workforce state at business and knowledge time | ADR 0011; explicit `(tenant_record_id, effective_on, known_at)` coordinate | `build_workforce_composition_snapshot` reuses bitemporal Employment and Assignment intervals | historical before/after correction, future-effective/late-recorded exclusion, and timezone-aware cutoff regressions | +| Reconstruct workforce state at business and knowledge time | ADR 0011; explicit `(tenant_record_id, effective_on, known_at)` coordinate with a detached UTC knowledge cutoff | `build_workforce_composition_snapshot` reuses bitemporal Employment and Assignment intervals after freezing the cutoff | historical before/after correction, future-effective/late-recorded exclusion, mutable-timezone, provider-failure, overflow, and canonical-reinjection regressions | | Avoid double-counting valid concurrent workers | ADR 0011 | distinct `person_record_id` set across visible reportable employments | concurrent-employment fixture expects 2 people from 3 reportable employments | | Reject impossible employment portfolios before aggregation | Existing employment-concurrency invariant + ADR 0011 | `_validate_visible_employment_portfolios` reuses `validate_person_employment_exclusivity` at the report coordinate | overlapping-exclusive-employment regression expects fail-closed `EmploymentExclusivityError` | | Preserve employment/FTE portfolio shape | ADR 0011 | employment count, staffed assignment count and Decimal staffed FTE remain separate aggregates | concurrent portfolio fixture expects 3 employments, 3 assignments and 1.5000 FTE | +| Reject impossible direct aggregate staffing | Builder staffing relationships and assignment allocation bounds | `WorkforceCompositionSnapshot._validate_canonical_invariants` rejects FTE without assignments, non-positive FTE with staffing, staffing without reportable employment/person totals, impossible assigned-person reconciliation, and FTE above the assignment count at construction and export | direct-construction staffing boundary regressions plus post-construction mutation export regression | +| Canonicalize direct staffed FTE evidence | ADR 0011; direct evidence must remain bounded and byte-stable | `_canonicalize_staffed_fte` accepts exact finite non-negative `Decimal` values with at most four fractional places, normalizes every mathematical zero to `0.0000`, checks nonzero FTE against staffed-assignment capacity before any digit padding, stores exactly four places, and export rechecks the canonical representation | positive/negative zero hash equivalence, million-place zero-exponent bounded-work regression, oversized nonzero exponent fail-closed regression, `0.5`/`0.5000` equivalence, and post-construction scale-mutation rejection | +| Canonicalize employment-status evidence | Aggregate evidence has one semantic representation and malformed caller containers must fail with domain errors | `_freeze_employment_status_counts` detaches row containers, requires exact two-value `(str, int)` rows with non-negative counts, omits zero-count rows, and export rejects any low-level reinjection that is not already canonical | malformed row-shape/type regressions, boolean/negative count regressions, zero-row omission equivalence, and post-construction zero-row mutation rejection | | Reject overfilled Position seats before aggregation | Existing position-seat invariant + ADR 0011 | each visible `position_record_id` is revalidated with `validate_position_seat_capacity` at the report coordinate | two distinct workers allocating 0.6000 each to one Position must raise `PositionSeatError` instead of reporting 1.2000 staffed FTE | -| Fail closed on inconsistent authoritative truth | Existing HRIS integrity contracts + ADR 0011 | single-valued Employment resolution, duplicate Assignment detection, assignment-employment coverage and allocation validation | contradictory Employment, duplicate Assignment, person mismatch and >1.0000 per-employment allocation regressions | +| Bound allocation arithmetic before exact aggregation | People/API four-decimal persistence scale + ADR 0011 | portfolio and Position-seat validators require exact finite `Decimal` allocation ratios in `(0, 1.0000]` with at most four fractional places before `_exact_decimal_total` | >4-place, non-finite and non-Decimal allocation regressions fail closed with governed domain errors | +| Fail closed on inconsistent authoritative truth | Existing HRIS integrity contracts + ADR 0011 | single-valued Employment resolution, duplicate Assignment detection, assignment-employment coverage and exact allocation validation | contradictory Employment, duplicate Assignment, person mismatch and >1.0000 per-employment/Position allocation regressions, including low-precision Decimal cases | | Prevent cross-tenant metric contamination | ADR 0003 + ADR 0011 | tenant scope is applied before reconstruction or aggregation | foreign-tenant employment/assignment fixture does not affect tenant metrics | | Minimize downstream PII | ADR 0011 | canonical JSON includes aggregate metrics, opaque tenant ID and report coordinates only | canonical evidence regression rejects row-level `person_record` / `employment_record` names | -| Make aggregate evidence reproducible | ADR 0011 | sorted status tuples, deterministic JSON encoding and SHA-256 over exact UTF-8 bytes | reversed-input fixture requires identical canonical JSON and digest; empty-workforce fixture requires stable empty status evidence | +| Make aggregate evidence reproducible | ADR 0011 | sorted nonzero status tuples, fixed-scale FTE, deterministic JSON encoding and SHA-256 over exact UTF-8 bytes | reversed-input fixture requires identical canonical JSON and digest; empty/zero-row and signed-zero fixtures require one canonical representation | +| Prevent post-construction evidence drift | Evidence objects must not retain mutable caller-owned time/status containers or trust low-level field mutation at export | snapshot construction stores exact UTC time and detached canonical status-count tuples; canonical export reruns temporal, tenant, aggregate, status-row, and FTE-representation invariants | mutable timezone, mutable/malformed status-container, zero-row reinjection, temporal reinjection, aggregate mutation, and FTE-scale mutation regressions fail closed | +| Keep workforce FTE evidence deterministic | ADR 0011; aggregate totals and deltas must not inherit caller Decimal context | shared `_exact_decimal_total` aligns validated Decimal coefficients before endpoint aggregation and change subtraction, so endpoint JSON, `staffed_fte_change`, canonical JSON, and digests are context-independent | ambient-precision regression compares low- and normal-precision endpoint and change evidence | | Keep workforce intelligence descriptive | ADR 0011 | module contains no recommendation, decision, protected-attribute inference or persistence API | public package boundary and code review; high-impact actions remain outside this slice | | Ground scope in current authoritative standards without claiming certification | ISO 30414:2025 public catalogue metadata; ADR 0011 | no proprietary ISO metric text is embedded in production code | `docs/doctoring/workforce-composition-references.md` | | Keep exact owned coverage reproducible | Orgmetra quality policy | `.github/workflows/workforce-intelligence-quality.yml` checks exact candidate SHA and runs the complete HRIS kernel | hosted exact-head workflow with package 100% statement/branch threshold | diff --git a/manifest.json b/manifest.json index 97f2bab14..8836d30b8 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"32cc4ef78d1eca557fa01731026840be01211a043eb0ada552e4e6cb9eace353","bytes":17295,"lines":76},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac","bytes":11185,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8","bytes":16534,"lines":135},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e","bytes":11462,"lines":40},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002","bytes":1838,"lines":18},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"595e8381dbd62e97093b11eef818af5f04d6473ac592d57e3985ffbc2210d445","bytes":28173,"lines":689},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"918cf92fd18d81572e9bd5f5daa7f033c32731e2e13f0d00661d1c1de30b12a9","bytes":27291,"lines":638}]} +{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"e859db116605ff05ad3ee4c4d88ec857756a4ec46cbb1528b350670f449a9b4f","bytes":17963,"lines":79},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac","bytes":11185,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8","bytes":16534,"lines":135},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e","bytes":11462,"lines":40},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"9e74051c1d37454e5ba4d35bd97eddd96429125faa6dd81a40220b468593f566","bytes":8232,"lines":60},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002","bytes":1838,"lines":18},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"595e8381dbd62e97093b11eef818af5f04d6473ac592d57e3985ffbc2210d445","bytes":28173,"lines":689},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"918cf92fd18d81572e9bd5f5daa7f033c32731e2e13f0d00661d1c1de30b12a9","bytes":27291,"lines":638}]} \ No newline at end of file diff --git a/packages/hris-kernel/CHANGELOG.md b/packages/hris-kernel/CHANGELOG.md new file mode 100644 index 000000000..23dca5b4c --- /dev/null +++ b/packages/hris-kernel/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## Unreleased + +- Add `WorkforceCompositionChangeSnapshot` for deterministic same-tenant comparison of two effective-date workforce-composition states at one exact recorded-time cutoff. +- Report aggregate net changes for distinct-person headcount, reportable employments, staffed assignments, staffed Decimal FTE, unassigned people, and status counts without serializing row-level HR identities. +- Fail closed on cross-tenant endpoints, non-forward effective dates, and different knowledge cutoffs so recorded corrections cannot masquerade as business-time workforce movement. +- Freeze timezone-aware knowledge cutoffs to detached UTC datetimes and copy status-count containers before canonical serialization, preventing mutable caller objects or timezone providers from changing snapshot evidence after construction. +- Canonicalize every mathematical staffed-FTE zero to exact `0.0000`, reject oversized nonzero evidence against staffed-assignment capacity before any representation expansion, normalize accepted direct FTE to four fractional places, and recheck that representation during export. +- Canonicalize employment-status evidence by requiring exact two-value `(str, int)` rows, rejecting malformed/boolean/negative counts with governed domain errors, and omitting semantic zero-count rows so equivalent aggregates have one JSON/hash representation. +- Revalidate every workforce aggregate and temporal invariant immediately before canonical JSON or digest export, so low-level post-construction mutation cannot become new audit evidence. +- Revalidate comparison endpoint runtime types, tenant identity, forward effective-date order, and one exact knowledge cutoff immediately before change-evidence export, preventing low-level endpoint mutation from bypassing the constructor contract. +- Require every allocation ratio reaching employment-portfolio or Position-seat aggregation to be an exact finite `Decimal` in `(0, 1.0000]` with at most four fractional places, preventing extreme-scale values from reaching exact coefficient arithmetic. +- Keep the contract descriptive: endpoint deltas are not labeled as hires, separations, transfers, turnover, causes, forecasts, protected-attribute effects, or employment recommendations. diff --git a/packages/hris-kernel/README.md b/packages/hris-kernel/README.md index 655f998df..1df96c3cc 100644 --- a/packages/hris-kernel/README.md +++ b/packages/hris-kernel/README.md @@ -12,10 +12,13 @@ Use this package to: 6. Build a CloudEvents 1.0-compatible audit/outbox envelope that carries tenant, actor, purpose, reason, evidence version, result, and accountable human-confirmation references without copying mutable HR payload fields into a shadow system of record. 7. Build a deterministic `JobAnalysisSnapshot` that links observable Tasks to explicit KSAO requirements, retains source/version/digest provenance, and optionally carries historical Functional Job Analysis Data/People/Things codes without confusing Job with Position or Assignment. 8. Build a deterministic `WorkforceCompositionSnapshot` for one tenant, effective day, and recorded-time cutoff with distinct-person headcount, reportable employment count, staffed assignment count/FTE, unassigned-person count, and status counts without serializing row-level person, employment, position, or assignment identifiers. +9. Compare two `WorkforceCompositionSnapshot` endpoints with `WorkforceCompositionChangeSnapshot` while holding one exact recorded-time cutoff constant, producing deterministic aggregate net changes without mislabeling endpoint subtraction as hires, separations, turnover, causes, or recommendations. Every historical reconstruction and portfolio/capacity decision requires an explicit `tenant_record_id`. A colliding durable identifier from another tenant is ignored rather than treated as local employment truth. -`WorkforceCompositionSnapshot` is descriptive reporting evidence, not an employment recommendation or decision. It derives `active` and `leave` workforce composition from the same authoritative bitemporal facts and assignment-integrity rules used by the HRIS kernel. Concurrent employments count one person once for headcount while employment count and staffed FTE retain the actual portfolio shape. Contradictory visible versions, duplicate assignment identities, invalid coverage, and over-allocation fail closed instead of becoming plausible-looking metrics. Canonical snapshot JSON carries only the opaque tenant identifier, report coordinate, aggregate metrics, and schema version; `content_digest()` addresses those exact UTF-8 bytes with SHA-256. +`WorkforceCompositionSnapshot` is descriptive reporting evidence, not an employment recommendation or decision. It derives `active` and `leave` workforce composition from the same authoritative bitemporal facts and assignment-integrity rules used by the HRIS kernel. Concurrent employments count one person once for headcount while employment count and staffed FTE retain the actual portfolio shape. Contradictory visible versions, duplicate assignment identities, invalid coverage, and over-allocation fail closed instead of becoming plausible-looking metrics. Construction detaches the timezone-aware knowledge cutoff to an exact UTC datetime and copies status-count containers before canonicalization, so caller mutation cannot rewrite evidence. Canonical snapshot JSON carries only the opaque tenant identifier, report coordinate, aggregate metrics, and schema version; `content_digest()` addresses those exact UTF-8 bytes with SHA-256. + +`WorkforceCompositionChangeSnapshot` compares an earlier and later effective date only when both aggregate endpoints belong to the same tenant and were reconstructed at the same exact `known_at` coordinate. That same-cutoff rule prevents later-recorded corrections from masquerading as business-time movement. The comparison reports exact net changes in person headcount, employment count, staffed assignments, staffed `Decimal` FTE, unassigned people, and status counts; its canonical evidence includes only aggregate endpoint JSON/digests and aggregate deltas. It intentionally does not infer event-level hires, separations, transfers, turnover, protected-attribute effects, causes, forecasts, or employment recommendations. `AuditOutboxEvent` fails closed on runtime type confusion, reserved nil UUID identities, ambiguous occurrence time, one-word/noncanonical source-service identifiers, malformed event types, free-text data placed in opaque-reference fields, noncanonical purpose/reason/result codes, whitespace-bearing evidence-version tokens, and missing confirmation for high-impact events. Source services use two-or-more-word `snake_case`; event types use the lower-case `orgmetra..` namespace; resource, actor, and confirmation identifiers use namespaced opaque references rather than human-readable payload text. @@ -25,4 +28,4 @@ Every historical reconstruction and portfolio/capacity decision requires an expl `AuditOutboxEvent.canonical_json()` is the exact deterministic JSON text that the owning service persists. `AuditOutboxEvent.content_digest()` is SHA-256 over the UTF-8 bytes of that exact text. Callers must not independently serialize `to_cloudevent()` with library defaults and then assume the digest still addresses the stored representation. The Orgmetra PostgreSQL persistence boundary in migration `0003_audit_outbox_persistence.sql` reparses and allowlists that envelope, verifies tenant/event identity and high-impact confirmation, recomputes the digest over the supplied bytes, writes immutable `audit_event_record` evidence, and creates separate `outbox_delivery_record` transport state. The owning service calls `record_audit_outbox_event(...)` inside the same transaction as its business mutation. -This kernel itself does not talk to PostgreSQL, Keyverse, O*NET, or any other service. Persistence, authorization, source retrieval, and UI stay at their adapter boundaries. Production dispatcher claiming, retry scheduling, lease-expiry recovery, retention/export, external delivery receipts, job-analysis persistence, SME workflow, workforce-report authorization/presentation, and selection-validity computation remain separately proven integration/operability work. +This kernel itself does not talk to PostgreSQL, Keyverse, O*NET, or any other service. Persistence, authorization, source retrieval, and UI stay at their adapter boundaries. Production dispatcher claiming, retry scheduling, lease-expiry recovery, retention/export, external delivery receipts, job-analysis persistence, SME workflow, workforce-report authorization/presentation, event-specific mobility/turnover computation, and selection-validity computation remain separately proven integration/operability work. diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/__init__.py b/packages/hris-kernel/src/orgmetra_hris_kernel/__init__.py index 5d4b720fa..9a41eda74 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/__init__.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/__init__.py @@ -50,6 +50,10 @@ WorkforceCompositionSnapshot, build_workforce_composition_snapshot, ) +from orgmetra_hris_kernel.workforce_change import ( + WorkforceCompositionChangeSnapshot, + build_workforce_composition_change_snapshot, +) __all__ = [ "AssignmentFact", @@ -76,7 +80,9 @@ "SingleValuedFactError", "TaskEvidence", "TaskKSAOLink", + "WorkforceCompositionChangeSnapshot", "WorkforceCompositionSnapshot", + "build_workforce_composition_change_snapshot", "build_workforce_composition_snapshot", "close_recorded_interval", "resolve_bitemporal_facts", diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py index 710ecbcd7..1ca76dbae 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/assignment.py @@ -18,15 +18,60 @@ _ONE = Decimal("1.0000") _ZERO = Decimal("0") +_ZERO_FTE = Decimal("0.0000") +_MAX_ALLOCATION_DECIMAL_PLACES = 4 _ASSIGNMENT_ELIGIBLE_EMPLOYMENT_STATUSES = frozenset({"active", "leave"}) _STAFFABLE_POSITION_STATUSES = frozenset({"active", "open"}) +def _exact_decimal_total(values: tuple[Decimal, ...]) -> Decimal: + """Sum finite, scale-bounded Decimal values exactly without ambient precision.""" + if not values: + return _ZERO_FTE + parts = tuple(value.as_tuple() for value in values) + common_exponent = min(part.exponent for part in parts) + coefficient = sum( + (1, -1)[part.sign] + * int("".join(map(str, part.digits))) + * 10 ** (part.exponent - common_exponent) + for part in parts + ) + return Decimal( + ( + int(coefficient < 0), + tuple(int(digit) for digit in str(abs(coefficient))), + common_exponent, + ) + ) + + def _ratio_is_valid(allocation_ratio: Decimal) -> bool: - """Return whether one assignment row stays inside (0, 1.0000].""" + """Return whether one allocation is an exact finite Decimal in (0, 1.0000].""" + if type(allocation_ratio) is not Decimal: + return False + if not allocation_ratio.is_finite(): + return False + if allocation_ratio.as_tuple().exponent < -_MAX_ALLOCATION_DECIMAL_PLACES: + return False return allocation_ratio > _ZERO and allocation_ratio <= _ONE +def _raise_invalid_portfolio_ratio() -> None: + """Raise the governed row-level allocation error used by portfolio validation.""" + raise AssignmentPortfolioError( + "allocation_ratio must be a finite Decimal greater than 0, at most 1.0000, and use at most four decimal places.", + next_action="Enter an allocation between 0.0001 and 1.0000 with at most four decimal places, then save.", + ) + + +def _raise_invalid_position_ratio() -> None: + """Raise the governed row-level allocation error used by seat validation.""" + raise PositionSeatError( + "allocation_ratio must be a finite Decimal greater than 0, at most 1.0000, and use at most four decimal places.", + next_action="Enter an allocation between 0.0001 and 1.0000 with at most four decimal places, then save.", + ) + + def _union_covers(intervals: list[DateInterval], target: DateInterval) -> bool: """Return whether merged employment periods cover the assignment period.""" cursor = target.start @@ -60,7 +105,7 @@ def validate_assignment_portfolio( known_at: The knowledge cutoff used for the review. Raises: - AssignmentPortfolioError: Reduce one allocation, then save again. + AssignmentPortfolioError: Reduce or correct one allocation, then save again. """ scoped = [ fact @@ -71,10 +116,7 @@ def validate_assignment_portfolio( ] for fact in scoped: if not _ratio_is_valid(fact.allocation_ratio): - raise AssignmentPortfolioError( - "allocation_ratio must be greater than 0 and at most 1.0000.", - next_action="Enter an allocation between 0.0001 and 1.0000, then save.", - ) + _raise_invalid_portfolio_ratio() visible = resolve_bitemporal_facts( scoped, tenant_record_id=tenant_record_id, @@ -83,7 +125,7 @@ def validate_assignment_portfolio( effective_on=effective_on, known_at=known_at, ) - total = sum((fact.allocation_ratio for fact in visible), start=_ZERO) + total = _exact_decimal_total(tuple(fact.allocation_ratio for fact in visible)) if total > _ONE: raise AssignmentPortfolioError( "Visible allocations for one employment exceed 1.0000.", @@ -204,7 +246,7 @@ def validate_position_seat_capacity( known_at: The knowledge cutoff used for the review. Raises: - PositionSeatError: Reduce one allocation so the seat total is at most 1.0000. + PositionSeatError: Correct or reduce one allocation, then save again. """ scoped = [ fact @@ -220,7 +262,10 @@ def validate_position_seat_capacity( effective_on=effective_on, known_at=known_at, ) - total = sum((fact.allocation_ratio for fact in visible), start=_ZERO) + for fact in visible: + if not _ratio_is_valid(fact.allocation_ratio): + _raise_invalid_position_ratio() + total = _exact_decimal_total(tuple(fact.allocation_ratio for fact in visible)) if total > _ONE: raise PositionSeatError( "Visible allocations for one position exceed 1.0000.", diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/workforce.py b/packages/hris-kernel/src/orgmetra_hris_kernel/workforce.py index eddb09248..2b613de7b 100644 --- a/packages/hris-kernel/src/orgmetra_hris_kernel/workforce.py +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/workforce.py @@ -9,24 +9,139 @@ from collections import Counter from dataclasses import dataclass -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from decimal import Decimal import hashlib import json from uuid import UUID from orgmetra_hris_kernel.assignment import ( + _exact_decimal_total, validate_assignment_employment_coverage, validate_assignment_portfolio, validate_position_seat_capacity, ) from orgmetra_hris_kernel.employment import validate_person_employment_exclusivity -from orgmetra_hris_kernel.errors import IntervalError, SingleValuedFactError +from orgmetra_hris_kernel.errors import IdentityScopeError, IntervalError, SingleValuedFactError from orgmetra_hris_kernel.facts import AssignmentFact, EmploymentVersion from orgmetra_hris_kernel.resolution import resolve_single_valued_fact _WORKFORCE_INCLUDED_STATUSES = frozenset({"active", "leave"}) _ZERO_FTE = Decimal("0.0000") +_CANONICAL_FTE_EXPONENT = -4 + + +def _validate_snapshot_tenant_id(tenant_record_id: UUID) -> None: + """Require one exact, non-sentinel tenant UUID before emitting evidence.""" + if type(tenant_record_id) is not UUID or tenant_record_id.int in {0, (1 << 128) - 1}: + raise IdentityScopeError( + "Workforce snapshot tenant_record_id must be a canonical operational UUID.", + next_action="Resolve the authoritative non-sentinel tenant UUID, then rebuild the snapshot.", + ) + + +def _validate_snapshot_temporal_coordinate(effective_on: date, known_at: datetime) -> datetime: + """Validate and detach exact business and recorded-time values before evidence use.""" + if type(effective_on) is not date: + raise IntervalError( + "Workforce snapshot effective date must be a calendar date.", + next_action="Provide an exact business date, then rebuild the snapshot.", + ) + if type(known_at) is not datetime or known_at.tzinfo is None: + raise IntervalError( + "Workforce snapshot knowledge cutoff must be timezone-aware.", + next_action="Convert the knowledge cutoff to UTC, then rebuild the snapshot.", + ) + try: + offset = known_at.utcoffset() + except Exception as exc: # noqa: BLE001 - normalize provider behavior at trust boundary. + raise IntervalError( + "Workforce snapshot knowledge cutoff must be timezone-aware.", + next_action="Convert the knowledge cutoff to UTC, then rebuild the snapshot.", + ) from exc + if type(offset) is not timedelta: + raise IntervalError( + "Workforce snapshot knowledge cutoff must be timezone-aware.", + next_action="Convert the knowledge cutoff to UTC, then rebuild the snapshot.", + ) + try: + return (known_at.replace(tzinfo=None) - offset).replace(tzinfo=timezone.utc) + except OverflowError as exc: + raise IntervalError( + "Workforce snapshot knowledge cutoff must be timezone-aware.", + next_action="Convert the knowledge cutoff to UTC, then rebuild the snapshot.", + ) from exc + + +def _status_values(value: object) -> tuple[object, ...]: + """Detach one caller-owned status container or fail with the public domain error.""" + try: + return tuple(value) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + raise SingleValuedFactError( + "Workforce snapshot employment status evidence must contain status/count pairs.", + next_action="Rebuild the snapshot with two-value status/count pairs.", + ) from exc + + +def _freeze_employment_status_counts(value: object) -> tuple[tuple[str, int], ...]: + """Detach status rows, reject malformed values, and omit semantic zero rows.""" + frozen: list[tuple[str, int]] = [] + for raw_row in _status_values(value): + row = _status_values(raw_row) + if len(row) != 2: + raise SingleValuedFactError( + "Workforce snapshot employment status evidence must contain status/count pairs.", + next_action="Rebuild the snapshot with exactly two values in every status/count pair.", + ) + status, count = row + if type(status) is not str or type(count) is not int or count < 0: + raise SingleValuedFactError( + "Workforce snapshot employment status evidence must contain valid status/count pairs.", + next_action="Rebuild the snapshot with string status codes and non-negative integer counts.", + ) + if count == 0: + continue + frozen.append((status, count)) + return tuple(frozen) + + +def _canonicalize_staffed_fte(staffed_fte: Decimal, staffed_assignment_count: int) -> Decimal: + """Return bounded four-decimal FTE evidence without exponent-proportional work.""" + if type(staffed_assignment_count) is not int or staffed_assignment_count < 0: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot with a non-negative integer staffed-assignment count.", + ) + if type(staffed_fte) is not Decimal or not staffed_fte.is_finite() or staffed_fte < _ZERO_FTE: + raise SingleValuedFactError( + "Workforce snapshot staffed FTE must be a finite non-negative Decimal.", + next_action="Provide a finite non-negative Decimal FTE, then rebuild the snapshot.", + ) + if staffed_fte.is_zero(): + return _ZERO_FTE + if staffed_fte > Decimal(staffed_assignment_count): + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action=( + "Rebuild the snapshot so staffed FTE does not exceed one full allocation per staffed assignment." + ), + ) + parts = staffed_fte.as_tuple() + if parts.exponent < _CANONICAL_FTE_EXPONENT: + raise SingleValuedFactError( + "Workforce snapshot staffed FTE must use at most four decimal places.", + next_action="Round source allocation evidence to the governed four-decimal scale, then rebuild.", + ) + if parts.exponent == _CANONICAL_FTE_EXPONENT: + return staffed_fte + return Decimal( + ( + 0, + parts.digits + (0,) * (parts.exponent - _CANONICAL_FTE_EXPONENT), + _CANONICAL_FTE_EXPONENT, + ) + ) @dataclass(frozen=True, slots=True) @@ -50,13 +165,57 @@ class WorkforceCompositionSnapshot: employment_status_counts: tuple[tuple[str, int], ...] def __post_init__(self) -> None: - """Reject non-canonical direct evidence before it can be hashed or exported.""" - if self.known_at.utcoffset() is None: + """Freeze caller-owned values, then reject inconsistent evidence.""" + object.__setattr__( + self, + "known_at", + _validate_snapshot_temporal_coordinate(self.effective_on, self.known_at), + ) + object.__setattr__( + self, + "employment_status_counts", + _freeze_employment_status_counts(self.employment_status_counts), + ) + object.__setattr__( + self, + "staffed_fte", + _canonicalize_staffed_fte(self.staffed_fte, self.staffed_assignment_count), + ) + self._validate_canonical_invariants() + + def _validate_canonical_invariants(self) -> None: + """Revalidate every portable evidence invariant without mutating the snapshot.""" + if type(self.effective_on) is not date or ( + type(self.known_at) is not datetime or self.known_at.tzinfo is not timezone.utc + ): raise IntervalError( - "Workforce snapshot knowledge cutoff must be timezone-aware.", - next_action="Convert the knowledge cutoff to UTC, then rebuild the snapshot.", + "Workforce snapshot temporal evidence is not canonical.", + next_action="Rebuild the snapshot through its validated constructor, then export it again.", ) - status_codes = tuple(status for status, _count in self.employment_status_counts) + _validate_snapshot_tenant_id(self.tenant_record_id) + + aggregate_counts = ( + self.person_headcount, + self.employment_count, + self.staffed_assignment_count, + self.unassigned_person_count, + ) + if not all(type(value) is int and value >= 0 for value in aggregate_counts): + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action=( + "Rebuild the snapshot from authoritative HRIS facts so every aggregate count is a " + "non-negative integer." + ), + ) + + canonical_status_counts = _freeze_employment_status_counts(self.employment_status_counts) + if canonical_status_counts != self.employment_status_counts: + raise SingleValuedFactError( + "Workforce snapshot employment status evidence is not canonical.", + next_action="Rebuild the snapshot so zero-count or mutable status rows cannot reach export.", + ) + status_codes = tuple(status for status, _count in canonical_status_counts) if len(status_codes) != len(set(status_codes)): raise SingleValuedFactError( "Workforce snapshot contains a duplicate status code.", @@ -68,8 +227,71 @@ def __post_init__(self) -> None: next_action="Sort employment status counts by status code, then rebuild the snapshot.", ) + canonical_staffed_fte = _canonicalize_staffed_fte( + self.staffed_fte, + self.staffed_assignment_count, + ) + if self.staffed_fte.as_tuple() != canonical_staffed_fte.as_tuple(): + raise SingleValuedFactError( + "Workforce snapshot staffed FTE is not canonical four-decimal evidence.", + next_action="Rebuild the snapshot from governed four-decimal allocation evidence.", + ) + if self.staffed_assignment_count > 0 and self.staffed_fte <= _ZERO_FTE: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot so every staffed assignment contributes positive FTE.", + ) + if self.staffed_assignment_count > 0 and self.employment_count == 0: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot with reportable employment for every staffed assignment.", + ) + if self.staffed_assignment_count > 0 and self.person_headcount == 0: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot with a reportable person for every staffed assignment.", + ) + if self.person_headcount > self.employment_count or self.unassigned_person_count > self.person_headcount: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action=( + "Rebuild the snapshot from authoritative HRIS facts so headcount, employment, and " + "unassigned-person totals reconcile." + ), + ) + assigned_person_count = self.person_headcount - self.unassigned_person_count + if self.staffed_assignment_count == 0 and assigned_person_count != 0: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot so people without assignments are counted as unassigned.", + ) + if self.staffed_assignment_count > 0 and assigned_person_count == 0: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot with an assigned person for every staffed workforce.", + ) + if assigned_person_count > self.staffed_assignment_count: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot so every assigned person has a staffed assignment.", + ) + if any(status not in _WORKFORCE_INCLUDED_STATUSES for status in status_codes): + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action="Rebuild the snapshot using only reportable workforce employment statuses.", + ) + if sum(count for _status, count in canonical_status_counts) != self.employment_count: + raise SingleValuedFactError( + "Workforce snapshot aggregate values are internally inconsistent.", + next_action=( + "Rebuild the snapshot from authoritative HRIS facts so per-status employment counts " + "reconcile to the total employment count." + ), + ) + def canonical_json(self) -> str: """Return deterministic aggregate evidence suitable for audit correlation.""" + self._validate_canonical_invariants() payload = { "effective_on": self.effective_on.isoformat(), "employment_count": self.employment_count, @@ -80,9 +302,7 @@ def canonical_json(self) -> str: } for status, count in self.employment_status_counts ], - "known_at": self.known_at.astimezone(timezone.utc) - .isoformat() - .replace("+00:00", "Z"), + "known_at": self.known_at.isoformat().replace("+00:00", "Z"), "person_headcount": self.person_headcount, "schema_version": "orgmetra.workforce_composition.v1", "staffed_assignment_count": self.staffed_assignment_count, @@ -217,20 +437,17 @@ def build_workforce_composition_snapshot( Aggregate workforce counts and deterministic evidence without row-level PII. Raises: - IntervalError: ``known_at`` is timezone-naive or has no usable UTC offset. + IntervalError: ``effective_on`` is not an exact calendar date, or ``known_at`` + is not an exact timezone-aware datetime with a usable UTC offset. SingleValuedFactError: One employment or assignment has contradictory - visible versions. + visible versions, or direct aggregate evidence is inconsistent. EmploymentExclusivityError: A worker has malformed or overlapping exclusive employment at the report coordinate. EmploymentCoverageError: Existing assignment integrity rejects a worker link. AssignmentPortfolioError: Existing allocation integrity rejects visible FTE. PositionSeatError: Existing position-capacity integrity rejects visible FTE. """ - if known_at.utcoffset() is None: - raise IntervalError( - "Workforce snapshot knowledge cutoff must be timezone-aware.", - next_action="Convert the knowledge cutoff to UTC, then rebuild the snapshot.", - ) + known_at = _validate_snapshot_temporal_coordinate(effective_on, known_at) _validate_visible_employment_portfolios( employment_versions, @@ -254,7 +471,6 @@ def build_workforce_composition_snapshot( portfolio_keys: set[tuple[UUID, UUID]] = set() position_record_ids: set[UUID] = set() staffed_people: set[UUID] = set() - staffed_fte = _ZERO_FTE staffed_assignment_count = 0 for assignment in visible_assignments: @@ -266,7 +482,6 @@ def build_workforce_composition_snapshot( portfolio_keys.add((assignment.person_record_id, assignment.employment_record_id)) position_record_ids.add(assignment.position_record_id) staffed_people.add(assignment.person_record_id) - staffed_fte += assignment.allocation_ratio staffed_assignment_count += 1 for person_record_id, employment_record_id in portfolio_keys: @@ -296,7 +511,9 @@ def build_workforce_composition_snapshot( person_headcount=len(workforce_people), employment_count=len(visible_employments), staffed_assignment_count=staffed_assignment_count, - staffed_fte=staffed_fte, + staffed_fte=_exact_decimal_total( + tuple(assignment.allocation_ratio for assignment in visible_assignments) + ), unassigned_person_count=len(workforce_people - staffed_people), employment_status_counts=tuple(sorted(status_counts.items())), - ) + ) \ No newline at end of file diff --git a/packages/hris-kernel/src/orgmetra_hris_kernel/workforce_change.py b/packages/hris-kernel/src/orgmetra_hris_kernel/workforce_change.py new file mode 100644 index 000000000..546762644 --- /dev/null +++ b/packages/hris-kernel/src/orgmetra_hris_kernel/workforce_change.py @@ -0,0 +1,182 @@ +"""Deterministic bitemporal change evidence for aggregate workforce composition.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +import hashlib +import json +from uuid import UUID + +from orgmetra_hris_kernel.assignment import _exact_decimal_total +from orgmetra_hris_kernel.errors import IdentityScopeError, IntervalError +from orgmetra_hris_kernel.facts import AssignmentFact, EmploymentVersion +from orgmetra_hris_kernel.workforce import ( + WorkforceCompositionSnapshot, + _validate_snapshot_temporal_coordinate, + build_workforce_composition_snapshot, +) + + +@dataclass(frozen=True, slots=True) +class WorkforceCompositionChangeSnapshot: + """Compare two aggregate workforce states at one recorded-time cutoff. + + The two snapshots must belong to the same tenant and share one exact + ``known_at`` coordinate. This prevents a buyer-facing trend from silently + mixing effective-time change with later-recorded corrections. The result is + descriptive evidence only: it reports net composition change and does not + infer hires, separations, causes, protected attributes, or recommendations. + """ + + opening_snapshot: WorkforceCompositionSnapshot + closing_snapshot: WorkforceCompositionSnapshot + + def __post_init__(self) -> None: + """Fail closed when endpoint evidence or coordinates are not comparable.""" + self._validate_comparable_endpoints() + + def _validate_comparable_endpoints(self) -> None: + """Recheck exact endpoint evidence before comparing cross-endpoint coordinates.""" + if type(self.opening_snapshot) is not WorkforceCompositionSnapshot: + raise TypeError("opening_snapshot must be an exact WorkforceCompositionSnapshot") + if type(self.closing_snapshot) is not WorkforceCompositionSnapshot: + raise TypeError("closing_snapshot must be an exact WorkforceCompositionSnapshot") + + self.opening_snapshot._validate_canonical_invariants() + self.closing_snapshot._validate_canonical_invariants() + + if self.opening_snapshot.tenant_record_id != self.closing_snapshot.tenant_record_id: + raise IdentityScopeError( + "Workforce change snapshots must belong to the same tenant.", + next_action="Rebuild both snapshots inside one tenant boundary, then compare them again.", + ) + if self.opening_snapshot.effective_on >= self.closing_snapshot.effective_on: + raise IntervalError( + "The closing workforce effective date must be later than the opening date.", + next_action="Choose a later comparison date, then rebuild the workforce change snapshot.", + ) + if self.opening_snapshot.known_at != self.closing_snapshot.known_at: + raise IntervalError( + "Workforce change snapshots must share one exact knowledge cutoff.", + next_action=( + "Rebuild both effective-date snapshots with the same recorded-time cutoff so corrections " + "cannot masquerade as workforce movement." + ), + ) + + @property + def tenant_record_id(self) -> UUID: + """Return the authoritative tenant shared by both aggregate snapshots.""" + return self.opening_snapshot.tenant_record_id + + @property + def person_headcount_change(self) -> int: + """Return closing distinct-person headcount minus opening headcount.""" + return self.closing_snapshot.person_headcount - self.opening_snapshot.person_headcount + + @property + def employment_count_change(self) -> int: + """Return closing reportable-employment count minus opening count.""" + return self.closing_snapshot.employment_count - self.opening_snapshot.employment_count + + @property + def staffed_assignment_count_change(self) -> int: + """Return closing staffed-assignment count minus opening count.""" + return self.closing_snapshot.staffed_assignment_count - self.opening_snapshot.staffed_assignment_count + + @property + def staffed_fte_change(self) -> Decimal: + """Return exact closing staffed FTE minus opening staffed FTE.""" + return _exact_decimal_total( + ( + self.closing_snapshot.staffed_fte, + self.opening_snapshot.staffed_fte.copy_negate(), + ) + ) + + @property + def unassigned_person_count_change(self) -> int: + """Return closing unassigned-person count minus opening count.""" + return self.closing_snapshot.unassigned_person_count - self.opening_snapshot.unassigned_person_count + + @property + def employment_status_changes(self) -> tuple[tuple[str, int], ...]: + """Return deterministic per-status count deltas across the two snapshots.""" + opening = dict(self.opening_snapshot.employment_status_counts) + closing = dict(self.closing_snapshot.employment_status_counts) + status_codes = sorted(set(opening) | set(closing)) + return tuple((status, closing.get(status, 0) - opening.get(status, 0)) for status in status_codes) + + def canonical_json(self) -> str: + """Return deterministic aggregate-only comparison evidence for audit correlation.""" + self._validate_comparable_endpoints() + payload = { + "closing_snapshot": json.loads(self.closing_snapshot.canonical_json()), + "closing_snapshot_digest": self.closing_snapshot.content_digest(), + "employment_count_change": self.employment_count_change, + "employment_status_changes": [ + {"employment_count_change": change, "employment_status_code": status} + for status, change in self.employment_status_changes + ], + "opening_snapshot": json.loads(self.opening_snapshot.canonical_json()), + "opening_snapshot_digest": self.opening_snapshot.content_digest(), + "person_headcount_change": self.person_headcount_change, + "schema_version": "orgmetra.workforce_composition_change.v1", + "staffed_assignment_count_change": self.staffed_assignment_count_change, + "staffed_fte_change": format(self.staffed_fte_change, "f"), + "tenant_record_id": str(self.tenant_record_id), + "unassigned_person_count_change": self.unassigned_person_count_change, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + def content_digest(self) -> str: + """Return SHA-256 over the exact canonical UTF-8 comparison evidence.""" + return hashlib.sha256(self.canonical_json().encode("utf-8")).hexdigest() + + +def build_workforce_composition_change_snapshot( + employment_versions: list[EmploymentVersion], + assignments: list[AssignmentFact], + *, + tenant_record_id: UUID, + from_effective_on: date, + to_effective_on: date, + known_at: datetime, +) -> WorkforceCompositionChangeSnapshot: + """Build one same-cutoff aggregate workforce comparison from HRIS truth. + + Both endpoint snapshots reuse the existing workforce-composition integrity + checks. Consequently contradictory employment history, invalid assignment + coverage, over-allocation, duplicate visible assignment identities, and + ambiguous recorded-time facts fail closed before any change metric is + emitted. + + Args: + employment_versions: Bitemporal employment facts, including other tenants. + assignments: Bitemporal assignment facts, including other tenants. + tenant_record_id: Tenant namespace whose composition is compared. + from_effective_on: Earlier business date to reconstruct. + to_effective_on: Later business date to reconstruct. + known_at: One timezone-aware recorded-time cutoff shared by both endpoints. + + Returns: + Aggregate-only deterministic change evidence. + """ + known_at = _validate_snapshot_temporal_coordinate(from_effective_on, known_at) + opening = build_workforce_composition_snapshot( + employment_versions, + assignments, + tenant_record_id=tenant_record_id, + effective_on=from_effective_on, + known_at=known_at, + ) + closing = build_workforce_composition_snapshot( + employment_versions, + assignments, + tenant_record_id=tenant_record_id, + effective_on=to_effective_on, + known_at=known_at, + ) + return WorkforceCompositionChangeSnapshot(opening, closing) diff --git a/packages/hris-kernel/tests/test_allocation_and_snapshot_export_regressions.py b/packages/hris-kernel/tests/test_allocation_and_snapshot_export_regressions.py new file mode 100644 index 000000000..f7cdee7fb --- /dev/null +++ b/packages/hris-kernel/tests/test_allocation_and_snapshot_export_regressions.py @@ -0,0 +1,143 @@ +"""Review regressions for assignment precision and workforce evidence export.""" + +from datetime import date, datetime, timezone +from decimal import Decimal +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import ( + AssignmentFact, + AssignmentPortfolioError, + DateInterval, + PositionSeatError, + RecordedInterval, + SingleValuedFactError, + WorkforceCompositionSnapshot, + validate_assignment_portfolio, + validate_position_seat_capacity, +) + + +def _id(value: int) -> UUID: + """Return a stable opaque UUID fixture.""" + return UUID(int=value) + + +def _assignment(allocation_ratio: object) -> AssignmentFact: + """Build one visible assignment using an untrusted caller allocation value.""" + return AssignmentFact( + tenant_record_id=_id(1), + assignment_record_id=_id(201), + employment_record_id=_id(101), + person_record_id=_id(11), + position_record_id=_id(1201), + allocation_ratio=allocation_ratio, # type: ignore[arg-type] + effective=DateInterval(date(2026, 1, 1)), + recorded=RecordedInterval(datetime(2026, 1, 1, tzinfo=timezone.utc)), + ) + + +def _staffed_snapshot(staffed_fte: Decimal) -> WorkforceCompositionSnapshot: + """Build one directly constructed, otherwise valid staffed snapshot.""" + return WorkforceCompositionSnapshot( + tenant_record_id=_id(1), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + person_headcount=1, + employment_count=1, + staffed_assignment_count=1, + staffed_fte=staffed_fte, + unassigned_person_count=0, + employment_status_counts=(("active", 1),), + ) + + +def _validate_portfolio(assignment: AssignmentFact) -> None: + """Run the tenant-scoped portfolio boundary for one fixture assignment.""" + validate_assignment_portfolio( + [assignment], + tenant_record_id=_id(1), + person_record_id=_id(11), + employment_record_id=_id(101), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + ) + + +def test_snapshot_export_rejects_post_construction_aggregate_mutation() -> None: + """Canonical evidence must not emit aggregate state that bypassed construction checks.""" + snapshot = WorkforceCompositionSnapshot( + tenant_record_id=_id(1), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + person_headcount=1, + employment_count=1, + staffed_assignment_count=0, + staffed_fte=Decimal("0.0000"), + unassigned_person_count=1, + employment_status_counts=(("active", 1),), + ) + + object.__setattr__(snapshot, "staffed_fte", Decimal("0.5000")) + + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + snapshot.canonical_json() + + +def test_direct_snapshot_rejects_fte_scale_beyond_four_decimal_places() -> None: + """Every accepted direct FTE must remain bounded for exact comparison arithmetic.""" + with pytest.raises(SingleValuedFactError, match="staffed FTE"): + _staffed_snapshot(Decimal("1E-5000")) + + +def test_direct_snapshot_canonicalizes_equivalent_fte_scales() -> None: + """Equivalent FTE values must emit one canonical four-decimal evidence representation.""" + compact = _staffed_snapshot(Decimal("0.5")) + fixed_scale = _staffed_snapshot(Decimal("0.5000")) + + assert compact.staffed_fte == Decimal("0.5000") + assert compact.staffed_fte.as_tuple().exponent == -4 + assert compact.canonical_json() == fixed_scale.canonical_json() + assert compact.content_digest() == fixed_scale.content_digest() + + +def test_snapshot_export_rejects_post_construction_fte_scale_mutation() -> None: + """Canonical export must reject equivalent values whose governed FTE scale was bypassed.""" + snapshot = _staffed_snapshot(Decimal("0.5000")) + object.__setattr__(snapshot, "staffed_fte", Decimal("0.5")) + + with pytest.raises(SingleValuedFactError, match="canonical four-decimal"): + snapshot.canonical_json() + + +def test_portfolio_rejects_allocation_scale_beyond_four_decimal_places() -> None: + """Direct kernel facts must fail closed before exact aggregation can amplify scale.""" + with pytest.raises(AssignmentPortfolioError, match="allocation_ratio"): + _validate_portfolio(_assignment(Decimal("0.00001"))) + + +def test_portfolio_rejects_nonfinite_allocation_ratio() -> None: + """Non-finite Decimal values must become governed domain errors, not arithmetic faults.""" + with pytest.raises(AssignmentPortfolioError, match="allocation_ratio"): + _validate_portfolio(_assignment(Decimal("NaN"))) + + +def test_portfolio_rejects_non_decimal_allocation_ratio() -> None: + """Only exact Decimal values may cross the HRIS allocation validation boundary.""" + with pytest.raises(AssignmentPortfolioError, match="allocation_ratio"): + _validate_portfolio(_assignment("0.5000")) + + +def test_position_capacity_rejects_allocation_scale_beyond_four_decimal_places() -> None: + """Seat-capacity validation must reject unsafe Decimal scale before exact aggregation.""" + assignment = _assignment(Decimal("0.00001")) + + with pytest.raises(PositionSeatError, match="allocation_ratio"): + validate_position_seat_capacity( + [assignment], + tenant_record_id=_id(1), + position_record_id=_id(1201), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + ) diff --git a/packages/hris-kernel/tests/test_assignment_portfolio.py b/packages/hris-kernel/tests/test_assignment_portfolio.py index b064f3350..bd1bacfde 100644 --- a/packages/hris-kernel/tests/test_assignment_portfolio.py +++ b/packages/hris-kernel/tests/test_assignment_portfolio.py @@ -2,7 +2,7 @@ from dataclasses import replace from datetime import date -from decimal import Decimal +from decimal import Decimal, localcontext from uuid import UUID import pytest @@ -56,7 +56,7 @@ def test_portfolio_rejects_allocation_above_one_for_one_employment( assignment_record_id=UUID("10000000-0000-7000-8000-000000000303"), allocation_ratio=Decimal("0.3000"), ) - with pytest.raises(AssignmentPortfolioError, match="1.0000"): + with pytest.raises(AssignmentPortfolioError, match=r"1\.0000"): validate_assignment_portfolio( [jordan_icu_assignment, jordan_float_assignment, extra], tenant_record_id=TENANT, @@ -67,6 +67,26 @@ def test_portfolio_rejects_allocation_above_one_for_one_employment( ) +def test_portfolio_rejects_exact_overallocation_under_low_decimal_precision( + jordan_icu_assignment, + jordan_float_assignment, +) -> None: + """Allocation limits must use exact totals even when Decimal precision is low.""" + first = replace(jordan_icu_assignment, allocation_ratio=Decimal("0.5040")) + second = replace(jordan_float_assignment, allocation_ratio=Decimal("0.5040")) + with localcontext() as context: + context.prec = 2 + with pytest.raises(AssignmentPortfolioError, match=r"1\.0000"): + validate_assignment_portfolio( + [first, second], + tenant_record_id=TENANT, + person_record_id=JORDAN, + employment_record_id=JORDAN_EMPLOYMENT, + effective_on=date(2024, 5, 1), + known_at=utc(2024, 5, 1), + ) + + def test_portfolio_ignores_another_person_and_another_employment( jordan_icu_assignment, ) -> None: diff --git a/packages/hris-kernel/tests/test_position_coverage.py b/packages/hris-kernel/tests/test_position_coverage.py index e47591e31..6febd54f4 100644 --- a/packages/hris-kernel/tests/test_position_coverage.py +++ b/packages/hris-kernel/tests/test_position_coverage.py @@ -2,7 +2,7 @@ from dataclasses import replace from datetime import date -from decimal import Decimal +from decimal import Decimal, localcontext from uuid import UUID import pytest @@ -105,7 +105,7 @@ def test_riley_cannot_take_a_full_icu_seat_already_held_by_jordan( allocation_ratio=Decimal("1.0000"), effective=effective(date(2024, 4, 1)), ) - with pytest.raises(PositionSeatError, match="1.0000"): + with pytest.raises(PositionSeatError, match=r"1\.0000"): validate_position_seat_capacity( [jordan_icu_assignment, riley], tenant_record_id=TENANT, @@ -115,6 +115,30 @@ def test_riley_cannot_take_a_full_icu_seat_already_held_by_jordan( ) +def test_position_rejects_exact_overallocation_under_low_decimal_precision( + jordan_icu_assignment, +) -> None: + """Seat limits must use exact totals even when Decimal precision is low.""" + riley = replace( + jordan_icu_assignment, + assignment_record_id=UUID("10000000-0000-7000-8000-000000000311"), + employment_record_id=RILEY_EMPLOYMENT, + person_record_id=RILEY, + allocation_ratio=Decimal("0.5040"), + ) + jordan = replace(jordan_icu_assignment, allocation_ratio=Decimal("0.5040")) + with localcontext() as context: + context.prec = 2 + with pytest.raises(PositionSeatError, match=r"1\.0000"): + validate_position_seat_capacity( + [jordan, riley], + tenant_record_id=TENANT, + position_record_id=ICU_POSITION, + effective_on=date(2024, 4, 15), + known_at=utc(2024, 4, 15), + ) + + def test_foreign_tenant_assignment_does_not_consume_local_seat_capacity( jordan_icu_assignment, ) -> None: @@ -222,7 +246,7 @@ def test_assignment_write_composes_employment_position_and_seat_rules( person_record_id=RILEY, employment_record_version_id=UUID("10000000-0000-7000-8000-000000000226"), ) - with pytest.raises(PositionSeatError, match="1.0000"): + with pytest.raises(PositionSeatError, match=r"1\.0000"): validate_assignment_write( riley, [jordan_icu_assignment, riley], diff --git a/packages/hris-kernel/tests/test_workforce_canonical_evidence_hardening.py b/packages/hris-kernel/tests/test_workforce_canonical_evidence_hardening.py new file mode 100644 index 000000000..eedd1610a --- /dev/null +++ b/packages/hris-kernel/tests/test_workforce_canonical_evidence_hardening.py @@ -0,0 +1,203 @@ +"""Regression coverage for canonical workforce evidence hardening.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import ( + IdentityScopeError, + IntervalError, + SingleValuedFactError, + WorkforceCompositionChangeSnapshot, + WorkforceCompositionSnapshot, +) + + +def _snapshot( + effective_on: date, + *, + tenant_record_id: UUID = UUID(int=1), + known_at: datetime = datetime(2026, 2, 20, tzinfo=timezone.utc), + staffed_fte: Decimal = Decimal("0.0000"), + employment_status_counts: tuple[tuple[str, int], ...] = (("active", 1),), +) -> WorkforceCompositionSnapshot: + """Return one valid unassigned-worker aggregate endpoint.""" + return WorkforceCompositionSnapshot( + tenant_record_id=tenant_record_id, + effective_on=effective_on, + known_at=known_at, + person_headcount=1, + employment_count=1, + staffed_assignment_count=0, + staffed_fte=staffed_fte, + unassigned_person_count=1, + employment_status_counts=employment_status_counts, + ) + + +def test_malformed_status_rows_fail_with_domain_error() -> None: + """Malformed status rows must not leak tuple-unpacking implementation errors.""" + with pytest.raises(SingleValuedFactError, match="status"): + _snapshot( + date(2026, 1, 15), + employment_status_counts=(("active", 1, 0),), # type: ignore[arg-type] + ) + + with pytest.raises(SingleValuedFactError, match="status"): + _snapshot( + date(2026, 1, 15), + employment_status_counts=(None,), # type: ignore[arg-type] + ) + + +def test_invalid_staffed_assignment_count_fails_with_domain_error() -> None: + """FTE canonicalization must reject non-integer assignment counts before arithmetic.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + WorkforceCompositionSnapshot( + tenant_record_id=UUID(int=1), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + person_headcount=1, + employment_count=1, + staffed_assignment_count=True, # type: ignore[arg-type] + staffed_fte=Decimal("0.0000"), + unassigned_person_count=1, + employment_status_counts=(("active", 1),), + ) + + +def test_equivalent_zero_evidence_has_one_canonical_representation() -> None: + """Negative Decimal zero and zero-count status rows must not fork evidence digests.""" + positive_zero = _snapshot(date(2026, 1, 15), staffed_fte=Decimal("0.0000")) + negative_zero = _snapshot(date(2026, 1, 15), staffed_fte=Decimal("-0.0000")) + + assert negative_zero.staffed_fte == Decimal("0.0000") + assert format(negative_zero.staffed_fte, "f") == "0.0000" + assert negative_zero.canonical_json() == positive_zero.canonical_json() + assert negative_zero.content_digest() == positive_zero.content_digest() + + empty = WorkforceCompositionSnapshot( + tenant_record_id=UUID(int=1), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + person_headcount=0, + employment_count=0, + staffed_assignment_count=0, + staffed_fte=Decimal("0.0000"), + unassigned_person_count=0, + employment_status_counts=(), + ) + zero_row = WorkforceCompositionSnapshot( + tenant_record_id=UUID(int=1), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + person_headcount=0, + employment_count=0, + staffed_assignment_count=0, + staffed_fte=Decimal("0.0000"), + unassigned_person_count=0, + employment_status_counts=(("active", 0),), + ) + assert zero_row.employment_status_counts == () + assert zero_row.canonical_json() == empty.canonical_json() + assert zero_row.content_digest() == empty.content_digest() + + +def test_export_rejects_post_construction_zero_status_row() -> None: + """Low-level mutation cannot reintroduce a noncanonical semantic-zero status row.""" + snapshot = _snapshot(date(2026, 1, 15)) + object.__setattr__( + snapshot, + "employment_status_counts", + (("active", 1), ("leave", 0)), + ) + + with pytest.raises(SingleValuedFactError, match="not canonical"): + snapshot.canonical_json() + + +def test_large_zero_exponent_canonicalizes_without_representation_expansion() -> None: + """A mathematically zero FTE never needs exponent-proportional digit padding.""" + snapshot = _snapshot(date(2026, 1, 15), staffed_fte=Decimal("0E+1000000")) + assert snapshot.staffed_fte == Decimal("0.0000") + assert format(snapshot.staffed_fte, "f") == "0.0000" + + +def test_large_nonzero_exponent_is_rejected_before_representation_expansion() -> None: + """Oversized nonzero FTE is rejected against assignment capacity before digit padding.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + WorkforceCompositionSnapshot( + tenant_record_id=UUID(int=1), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + person_headcount=1, + employment_count=1, + staffed_assignment_count=1, + staffed_fte=Decimal("1E+1000000"), + unassigned_person_count=0, + employment_status_counts=(("active", 1),), + ) + + +def test_change_export_rechecks_tenant_after_endpoint_mutation() -> None: + """Cross-tenant endpoint mutation must fail before comparison evidence is emitted.""" + opening = _snapshot(date(2026, 1, 15)) + closing = _snapshot(date(2026, 2, 15)) + change = WorkforceCompositionChangeSnapshot(opening, closing) + object.__setattr__(closing, "tenant_record_id", UUID(int=2)) + + with pytest.raises(IdentityScopeError, match="same tenant"): + change.canonical_json() + + +def test_change_export_rechecks_date_order_after_endpoint_mutation() -> None: + """Post-construction date mutation must not turn a forward comparison backward.""" + opening = _snapshot(date(2026, 1, 15)) + closing = _snapshot(date(2026, 2, 15)) + change = WorkforceCompositionChangeSnapshot(opening, closing) + object.__setattr__(opening, "effective_on", date(2026, 3, 1)) + + with pytest.raises(IntervalError, match="later"): + change.canonical_json() + + +def test_change_export_rechecks_malformed_endpoint_date_before_comparison() -> None: + """Malformed endpoint time must fail with the snapshot domain contract, not raw TypeError.""" + opening = _snapshot(date(2026, 1, 15)) + closing = _snapshot(date(2026, 2, 15)) + change = WorkforceCompositionChangeSnapshot(opening, closing) + object.__setattr__(opening, "effective_on", "2026-01-15") + + with pytest.raises(IntervalError, match="temporal evidence"): + change.canonical_json() + + +def test_change_export_rechecks_cutoff_after_endpoint_mutation() -> None: + """Post-construction recorded-time mutation must not mix knowledge cutoffs.""" + opening = _snapshot(date(2026, 1, 15)) + closing = _snapshot(date(2026, 2, 15)) + change = WorkforceCompositionChangeSnapshot(opening, closing) + object.__setattr__( + closing, + "known_at", + datetime(2026, 2, 20, tzinfo=timezone.utc) + timedelta(seconds=1), + ) + + with pytest.raises(IntervalError, match="knowledge cutoff"): + change.canonical_json() + + +def test_change_export_rechecks_exact_endpoint_types_after_mutation() -> None: + """Low-level endpoint replacement must fail with the public type contract.""" + change = WorkforceCompositionChangeSnapshot( + _snapshot(date(2026, 1, 15)), + _snapshot(date(2026, 2, 15)), + ) + object.__setattr__(change, "opening_snapshot", object()) + + with pytest.raises(TypeError, match="opening_snapshot"): + change.canonical_json() diff --git a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py index 4661d5af6..b98b37125 100644 --- a/packages/hris-kernel/tests/test_workforce_composition_boundaries.py +++ b/packages/hris-kernel/tests/test_workforce_composition_boundaries.py @@ -39,17 +39,22 @@ def _direct_snapshot( *, known_at: datetime, employment_status_counts: tuple[tuple[str, int], ...] = (("active", 1),), + person_headcount: int = 1, + employment_count: int = 1, + staffed_assignment_count: int = 0, + staffed_fte: Decimal = Decimal("0.0000"), + unassigned_person_count: int = 1, ) -> WorkforceCompositionSnapshot: """Build a direct public snapshot fixture without using the aggregate builder.""" return WorkforceCompositionSnapshot( tenant_record_id=_id(1), effective_on=date(2026, 1, 15), known_at=known_at, - person_headcount=1, - employment_count=1, - staffed_assignment_count=0, - staffed_fte=Decimal("0.0000"), - unassigned_person_count=1, + person_headcount=person_headcount, + employment_count=employment_count, + staffed_assignment_count=staffed_assignment_count, + staffed_fte=staffed_fte, + unassigned_person_count=unassigned_person_count, employment_status_counts=employment_status_counts, ) @@ -84,6 +89,177 @@ def test_direct_snapshot_rejects_duplicate_status_codes() -> None: ) +def test_direct_snapshot_rejects_negative_aggregate_counts() -> None: + """Portable evidence cannot hash a negative workforce count.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + person_headcount=-1, + ) + + +def test_direct_snapshot_rejects_boolean_aggregate_counts() -> None: + """Boolean values must not masquerade as integer workforce counts.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + employment_count=True, + ) + + +def test_direct_snapshot_rejects_unassigned_count_above_headcount() -> None: + """Unassigned people cannot exceed the distinct people represented.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + unassigned_person_count=2, + ) + + +def test_direct_snapshot_rejects_nonfinite_staffed_fte() -> None: + """NaN or infinite FTE values cannot enter deterministic audit evidence.""" + with pytest.raises(SingleValuedFactError, match="staffed FTE"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + staffed_fte=Decimal("NaN"), + ) + + +def test_direct_snapshot_rejects_non_decimal_staffed_fte() -> None: + """FTE evidence must remain Decimal so finite and canonical formatting are guaranteed.""" + with pytest.raises(SingleValuedFactError, match="staffed FTE"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + staffed_fte=0, # type: ignore[arg-type] + ) + + +def test_direct_snapshot_rejects_fte_without_staffed_assignments() -> None: + """Zero staffed assignments cannot carry positive FTE evidence.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + staffed_fte=Decimal("0.0001"), + ) + + +def test_direct_snapshot_rejects_zero_fte_with_staffed_assignments() -> None: + """A staffed assignment must contribute a positive allocation.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + staffed_assignment_count=1, + staffed_fte=Decimal("0.0000"), + unassigned_person_count=0, + ) + + +def test_direct_snapshot_rejects_staffing_without_employment_totals() -> None: + """Staffing cannot exist when no reportable employment is represented.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + employment_status_counts=(), + employment_count=0, + staffed_assignment_count=1, + staffed_fte=Decimal("0.5000"), + unassigned_person_count=0, + ) + + +def test_direct_snapshot_rejects_staffing_without_people() -> None: + """Staffing cannot exist when no reportable person is represented.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + person_headcount=0, + staffed_assignment_count=1, + staffed_fte=Decimal("0.5000"), + unassigned_person_count=0, + ) + + +def test_direct_snapshot_rejects_fte_above_staffed_assignment_count() -> None: + """Each staffed assignment contributes at most one full-time allocation.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + staffed_assignment_count=1, + staffed_fte=Decimal("1.0001"), + unassigned_person_count=0, + ) + + +def test_direct_snapshot_rejects_assigned_person_without_staffing() -> None: + """A person counted as assigned requires at least one staffed assignment.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + unassigned_person_count=0, + ) + + +def test_direct_snapshot_rejects_staffing_when_every_person_is_unassigned() -> None: + """Staffed assignments cannot coexist with an entirely unassigned workforce.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + staffed_assignment_count=1, + staffed_fte=Decimal("0.5000"), + unassigned_person_count=1, + ) + + +def test_direct_snapshot_rejects_more_assigned_people_than_assignments() -> None: + """Distinct assigned people cannot exceed the number of staffed assignments.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + person_headcount=2, + employment_count=2, + employment_status_counts=(("active", 2),), + staffed_assignment_count=1, + staffed_fte=Decimal("0.5000"), + unassigned_person_count=0, + ) + + +def test_direct_snapshot_rejects_boolean_status_counts() -> None: + """Boolean values must not serialize as employment counts.""" + with pytest.raises(SingleValuedFactError, match="status"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + employment_status_counts=(("active", True),), # type: ignore[tuple-item] + ) + + +def test_direct_snapshot_rejects_negative_status_counts() -> None: + """Negative per-status counts cannot reconcile a workforce aggregate.""" + with pytest.raises(SingleValuedFactError, match="status"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + employment_status_counts=(("active", -1), ("leave", 2)), + ) + + +def test_direct_snapshot_rejects_status_total_mismatch() -> None: + """Per-status employment counts must reconcile to total employment count.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + employment_status_counts=(("active", 2),), + ) + + +def test_direct_snapshot_rejects_nonreportable_status_code() -> None: + """Direct evidence cannot introduce a status outside the reportable workforce vocabulary.""" + with pytest.raises(SingleValuedFactError, match="internally inconsistent"): + _direct_snapshot( + known_at=datetime(2026, 1, 20, tzinfo=timezone.utc), + employment_status_counts=(("terminated", 1),), + ) + + def test_snapshot_excludes_future_business_and_late_recorded_facts() -> None: """Scheduled or not-yet-known facts cannot leak into an earlier workforce report.""" known_from_start = RecordedInterval(datetime(2026, 1, 1, tzinfo=timezone.utc)) diff --git a/packages/hris-kernel/tests/test_workforce_composition_change.py b/packages/hris-kernel/tests/test_workforce_composition_change.py new file mode 100644 index 000000000..d51679c60 --- /dev/null +++ b/packages/hris-kernel/tests/test_workforce_composition_change.py @@ -0,0 +1,301 @@ +"""Bitemporal workforce-composition change regressions.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone, tzinfo +from decimal import Decimal, localcontext +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel import ( + AssignmentFact, + DateInterval, + EmploymentVersion, + IdentityScopeError, + IntervalError, + RecordedInterval, + WorkforceCompositionChangeSnapshot, + WorkforceCompositionSnapshot, + build_workforce_composition_change_snapshot, + build_workforce_composition_snapshot, +) + + +def _id(value: int) -> UUID: + return UUID(int=value) + + +def _employment( + record_id: int, + version_id: int, + person_id: int, + *, + effective_start: date, + status: str = "active", +) -> EmploymentVersion: + return EmploymentVersion( + tenant_record_id=_id(1), + employment_record_id=_id(record_id), + employment_record_version_id=_id(version_id), + person_record_id=_id(person_id), + employment_status_code=status, + effective=DateInterval(effective_start), + recorded=RecordedInterval(datetime(2026, 1, 1, tzinfo=timezone.utc)), + ) + + +def _assignment( + assignment_id: int, + employment_id: int, + person_id: int, + *, + effective_start: date, + ratio: str = "1.0000", +) -> AssignmentFact: + return AssignmentFact( + tenant_record_id=_id(1), + assignment_record_id=_id(assignment_id), + employment_record_id=_id(employment_id), + person_record_id=_id(person_id), + position_record_id=_id(assignment_id + 1000), + allocation_ratio=Decimal(ratio), + effective=DateInterval(effective_start), + recorded=RecordedInterval(datetime(2026, 1, 1, tzinfo=timezone.utc)), + ) + + +def _source_facts() -> tuple[list[EmploymentVersion], list[AssignmentFact]]: + employments = [ + _employment(101, 1001, 11, effective_start=date(2026, 1, 1)), + _employment(102, 1002, 12, effective_start=date(2026, 1, 1), status="leave"), + _employment(103, 1003, 13, effective_start=date(2026, 2, 1)), + ] + assignments = [ + _assignment(201, 101, 11, effective_start=date(2026, 1, 1)), + _assignment(202, 102, 12, effective_start=date(2026, 1, 1), ratio="0.5000"), + _assignment(203, 103, 13, effective_start=date(2026, 2, 1)), + ] + return employments, assignments + + +def _aggregate_snapshot(effective_on: date, staffed_fte: str) -> WorkforceCompositionSnapshot: + """Build one valid aggregate endpoint with a controlled FTE spelling.""" + return WorkforceCompositionSnapshot( + tenant_record_id=_id(1), + effective_on=effective_on, + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + person_headcount=1, + employment_count=1, + staffed_assignment_count=1, + staffed_fte=Decimal(staffed_fte), + unassigned_person_count=0, + employment_status_counts=(("active", 1),), + ) + + +class _SequencedOffsetTimezone(tzinfo): + """Timezone provider that changes its offset on each request.""" + + def __init__(self) -> None: + self.calls = 0 + + def utcoffset(self, value: datetime | None) -> timedelta: + """Return different offsets to detect duplicate cutoff resolution.""" + self.calls += 1 + return timedelta(hours=self.calls - 1) + + +def test_change_snapshot_compares_two_effective_dates_at_one_knowledge_cutoff() -> None: + employments, assignments = _source_facts() + snapshot = build_workforce_composition_change_snapshot( + employments, + assignments, + tenant_record_id=_id(1), + from_effective_on=date(2026, 1, 15), + to_effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + ) + + assert snapshot.opening_snapshot.person_headcount == 2 + assert snapshot.closing_snapshot.person_headcount == 3 + assert snapshot.person_headcount_change == 1 + assert snapshot.employment_count_change == 1 + assert snapshot.staffed_assignment_count_change == 1 + assert snapshot.staffed_fte_change == Decimal("1.0000") + assert snapshot.unassigned_person_count_change == 0 + assert snapshot.employment_status_changes == (("active", 1), ("leave", 0)) + assert '"schema_version":"orgmetra.workforce_composition_change.v1"' in snapshot.canonical_json() + assert "person_record_id" not in snapshot.canonical_json() + assert len(snapshot.content_digest()) == 64 + + +def test_staffed_fte_change_is_independent_of_decimal_context_precision() -> None: + """FTE deltas and their evidence must not depend on a caller's Decimal precision.""" + snapshot = WorkforceCompositionChangeSnapshot( + _aggregate_snapshot(date(2026, 1, 15), "0.1234"), + _aggregate_snapshot(date(2026, 2, 15), "0.2345"), + ) + + with localcontext() as context: + context.prec = 2 + low_precision = ( + snapshot.staffed_fte_change, + snapshot.canonical_json(), + snapshot.content_digest(), + ) + with localcontext() as context: + context.prec = 28 + normal_precision = ( + snapshot.staffed_fte_change, + snapshot.canonical_json(), + snapshot.content_digest(), + ) + + assert low_precision == normal_precision + assert low_precision[0] == Decimal("0.1111") + + +def test_workforce_fte_evidence_is_independent_of_decimal_context_precision() -> None: + """Endpoint totals and change evidence must preserve every allocation digit.""" + employments = [ + _employment(101, 1001, 11, effective_start=date(2026, 1, 1)), + _employment(102, 1002, 12, effective_start=date(2026, 1, 1)), + _employment(103, 1003, 13, effective_start=date(2026, 1, 1)), + ] + assignments = [ + _assignment(201, 101, 11, effective_start=date(2026, 1, 1), ratio="0.1234"), + _assignment(202, 102, 12, effective_start=date(2026, 1, 1), ratio="0.2345"), + _assignment(203, 103, 13, effective_start=date(2026, 1, 1), ratio="0.3456"), + ] + + with localcontext() as context: + context.prec = 2 + low_precision_snapshot = build_workforce_composition_change_snapshot( + employments, + assignments, + tenant_record_id=_id(1), + from_effective_on=date(2026, 1, 15), + to_effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + ) + low_precision_evidence = ( + low_precision_snapshot.opening_snapshot.staffed_fte, + low_precision_snapshot.opening_snapshot.canonical_json(), + low_precision_snapshot.closing_snapshot.canonical_json(), + low_precision_snapshot.staffed_fte_change, + low_precision_snapshot.canonical_json(), + low_precision_snapshot.opening_snapshot.content_digest(), + low_precision_snapshot.closing_snapshot.content_digest(), + low_precision_snapshot.content_digest(), + ) + with localcontext() as context: + context.prec = 28 + normal_precision_snapshot = build_workforce_composition_change_snapshot( + employments, + assignments, + tenant_record_id=_id(1), + from_effective_on=date(2026, 1, 15), + to_effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + ) + normal_precision_evidence = ( + normal_precision_snapshot.opening_snapshot.staffed_fte, + normal_precision_snapshot.opening_snapshot.canonical_json(), + normal_precision_snapshot.closing_snapshot.canonical_json(), + normal_precision_snapshot.staffed_fte_change, + normal_precision_snapshot.canonical_json(), + normal_precision_snapshot.opening_snapshot.content_digest(), + normal_precision_snapshot.closing_snapshot.content_digest(), + normal_precision_snapshot.content_digest(), + ) + + assert low_precision_evidence == normal_precision_evidence + assert low_precision_evidence[0] == Decimal("0.7035") + assert low_precision_evidence[3] == Decimal("0.0000") + + +def test_change_builder_freezes_one_cutoff_before_building_both_endpoints() -> None: + """Both change endpoints must use one detached instant from a mutable provider.""" + provider = _SequencedOffsetTimezone() + snapshot = build_workforce_composition_change_snapshot( + [], + [], + tenant_record_id=_id(1), + from_effective_on=date(2026, 1, 15), + to_effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=provider), + ) + + assert provider.calls == 1 + assert snapshot.opening_snapshot.known_at == snapshot.closing_snapshot.known_at + assert snapshot.opening_snapshot.known_at == datetime(2026, 2, 20, tzinfo=timezone.utc) + + +def test_change_snapshot_is_deterministic_for_reordered_source_facts() -> None: + employments, assignments = _source_facts() + first = build_workforce_composition_change_snapshot( + employments, + assignments, + tenant_record_id=_id(1), + from_effective_on=date(2026, 1, 15), + to_effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + ) + second = build_workforce_composition_change_snapshot( + list(reversed(employments)), + list(reversed(assignments)), + tenant_record_id=_id(1), + from_effective_on=date(2026, 1, 15), + to_effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + ) + + assert first.canonical_json() == second.canonical_json() + assert first.content_digest() == second.content_digest() + + +def test_change_snapshot_rejects_non_forward_effective_window() -> None: + with pytest.raises(IntervalError, match="later") as error: + build_workforce_composition_change_snapshot( + [], + [], + tenant_record_id=_id(1), + from_effective_on=date(2026, 2, 15), + to_effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + ) + assert "Choose a later comparison date" in error.value.next_action + + +def test_direct_change_snapshot_rejects_cross_tenant_comparison() -> None: + known_at = datetime(2026, 2, 20, tzinfo=timezone.utc) + opening = build_workforce_composition_snapshot( + [], [], tenant_record_id=_id(1), effective_on=date(2026, 1, 15), known_at=known_at + ) + closing = build_workforce_composition_snapshot( + [], [], tenant_record_id=_id(2), effective_on=date(2026, 2, 15), known_at=known_at + ) + + with pytest.raises(IdentityScopeError, match="same tenant"): + WorkforceCompositionChangeSnapshot(opening, closing) + + +def test_direct_change_snapshot_rejects_different_knowledge_cutoffs() -> None: + opening = build_workforce_composition_snapshot( + [], + [], + tenant_record_id=_id(1), + effective_on=date(2026, 1, 15), + known_at=datetime(2026, 2, 19, tzinfo=timezone.utc), + ) + closing = build_workforce_composition_snapshot( + [], + [], + tenant_record_id=_id(1), + effective_on=date(2026, 2, 15), + known_at=datetime(2026, 2, 20, tzinfo=timezone.utc), + ) + + with pytest.raises(IntervalError, match="knowledge cutoff"): + WorkforceCompositionChangeSnapshot(opening, closing) diff --git a/packages/hris-kernel/tests/test_workforce_composition_change_evidence_types.py b/packages/hris-kernel/tests/test_workforce_composition_change_evidence_types.py new file mode 100644 index 000000000..d2e08e627 --- /dev/null +++ b/packages/hris-kernel/tests/test_workforce_composition_change_evidence_types.py @@ -0,0 +1,57 @@ +"""Regression coverage for exact workforce endpoint evidence types.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from decimal import Decimal +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel.workforce import WorkforceCompositionSnapshot +from orgmetra_hris_kernel.workforce_change import WorkforceCompositionChangeSnapshot + + +class ForgedSnapshot(WorkforceCompositionSnapshot): + """Snapshot subclass able to forge canonical endpoint evidence.""" + + def canonical_json(self) -> str: + """Return evidence unrelated to the inherited aggregate fields.""" + return '{"schema_version":"forged"}' + + def content_digest(self) -> str: + """Return a forged digest unrelated to the inherited aggregate fields.""" + return "f" * 64 + + +def snapshot(snapshot_type: type[WorkforceCompositionSnapshot], effective_on: date) -> WorkforceCompositionSnapshot: + """Build one internally consistent endpoint using the requested runtime type.""" + return snapshot_type( + tenant_record_id=UUID("11111111-1111-4111-8111-111111111111"), + effective_on=effective_on, + known_at=datetime(2026, 8, 21, 4, 40, tzinfo=timezone.utc), + person_headcount=1, + employment_count=1, + staffed_assignment_count=1, + staffed_fte=Decimal("1.0000"), + unassigned_person_count=0, + employment_status_counts=(("active", 1),), + ) + + +def test_rejects_opening_snapshot_subclass_that_can_forge_endpoint_evidence() -> None: + """Opening evidence must be the exact validated workforce snapshot runtime type.""" + opening = snapshot(ForgedSnapshot, date(2026, 8, 1)) + closing = snapshot(WorkforceCompositionSnapshot, date(2026, 8, 21)) + + with pytest.raises(TypeError, match="opening_snapshot"): + WorkforceCompositionChangeSnapshot(opening, closing) + + +def test_rejects_closing_snapshot_subclass_that_can_forge_endpoint_evidence() -> None: + """Closing evidence must be the exact validated workforce snapshot runtime type.""" + opening = snapshot(WorkforceCompositionSnapshot, date(2026, 8, 1)) + closing = snapshot(ForgedSnapshot, date(2026, 8, 21)) + + with pytest.raises(TypeError, match="closing_snapshot"): + WorkforceCompositionChangeSnapshot(opening, closing) diff --git a/packages/hris-kernel/tests/test_workforce_composition_temporal_integrity.py b/packages/hris-kernel/tests/test_workforce_composition_temporal_integrity.py new file mode 100644 index 000000000..f2d021a23 --- /dev/null +++ b/packages/hris-kernel/tests/test_workforce_composition_temporal_integrity.py @@ -0,0 +1,172 @@ +"""Regression coverage for workforce snapshot temporal evidence integrity.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone, tzinfo +from decimal import Decimal +from uuid import UUID + +import pytest + +from orgmetra_hris_kernel.errors import IdentityScopeError, IntervalError +from orgmetra_hris_kernel.workforce import WorkforceCompositionSnapshot + + +class ForgedDate(date): + """Date subclass able to forge canonical business-time evidence.""" + + def isoformat(self) -> str: + """Return a date different from the underlying effective date.""" + return "2099-12-31" + + +class ForgedDateTime(datetime): + """Datetime subclass able to forge canonical recorded-time evidence.""" + + def astimezone(self, tz=None): # type: ignore[no-untyped-def] + """Keep the hostile subclass alive across UTC normalization.""" + return self + + def isoformat(self, *args, **kwargs) -> str: # type: ignore[no-untyped-def] + """Return an instant different from the underlying knowledge cutoff.""" + return "2099-12-31T23:59:59+00:00" + + +class _MutableOffsetTimezone(tzinfo): + """Timezone provider whose offset can change after evidence construction.""" + + def __init__(self, offset: timedelta) -> None: + self.offset = offset + + def utcoffset(self, value: datetime | None) -> timedelta: + """Return the currently configured offset.""" + return self.offset + + +class _UnknownOffsetTimezone(tzinfo): + """Timezone provider whose offset is intentionally indeterminate.""" + + def utcoffset(self, value: datetime | None) -> None: + """Return no offset so the datetime is not a usable absolute instant.""" + return None + + +class _ExplodingOffsetTimezone(tzinfo): + """Timezone provider that raises while its offset is requested.""" + + def utcoffset(self, value: datetime | None) -> timedelta: + """Raise to verify provider failures become interval errors.""" + raise RuntimeError("offset provider unavailable") + + +class _WrongOffsetTimezone(tzinfo): + """Timezone provider that returns a non-timedelta offset.""" + + def utcoffset(self, value: datetime | None) -> str: # type: ignore[override] + """Return an invalid offset type at the trust boundary.""" + return "not-a-timedelta" + + +def snapshot(**overrides: object) -> WorkforceCompositionSnapshot: + """Build one internally consistent aggregate snapshot for boundary mutation tests.""" + values: dict[str, object] = { + "tenant_record_id": UUID("11111111-1111-4111-8111-111111111111"), + "effective_on": date(2026, 8, 21), + "known_at": datetime(2026, 8, 21, 4, 30, tzinfo=timezone.utc), + "person_headcount": 1, + "employment_count": 1, + "staffed_assignment_count": 1, + "staffed_fte": Decimal("1.0000"), + "unassigned_person_count": 0, + "employment_status_counts": (("active", 1),), + } + values.update(overrides) + return WorkforceCompositionSnapshot(**values) + + +def test_rejects_date_subclass_that_can_forge_effective_time_evidence() -> None: + """Canonical snapshots must not invoke caller-overridable date rendering.""" + with pytest.raises(IntervalError, match="effective date"): + snapshot(effective_on=ForgedDate(2026, 8, 21)) + + +def test_rejects_datetime_subclass_that_can_forge_recorded_time_evidence() -> None: + """Canonical snapshots must not invoke caller-overridable datetime rendering.""" + with pytest.raises(IntervalError, match="knowledge cutoff"): + snapshot(known_at=ForgedDateTime(2026, 8, 21, 4, 30, tzinfo=timezone.utc)) + + +def test_snapshot_freezes_mutable_timezone_before_canonical_export() -> None: + """Changing a caller-owned timezone cannot change stored evidence or its digest.""" + provider = _MutableOffsetTimezone(timedelta(hours=2)) + evidence = snapshot(known_at=datetime(2026, 8, 21, 4, 30, tzinfo=provider)) + canonical = evidence.canonical_json() + digest = evidence.content_digest() + + provider.offset = timedelta(hours=3) + + assert evidence.known_at == datetime(2026, 8, 21, 2, 30, tzinfo=timezone.utc) + assert evidence.canonical_json() == canonical + assert evidence.content_digest() == digest + + +@pytest.mark.parametrize( + "known_at", + [ + datetime.min.replace(tzinfo=timezone(timedelta(hours=1))), + datetime.max.replace(tzinfo=timezone(-timedelta(hours=1))), + datetime(2026, 8, 21, 4, 30, tzinfo=_UnknownOffsetTimezone()), + datetime(2026, 8, 21, 4, 30, tzinfo=_ExplodingOffsetTimezone()), + datetime(2026, 8, 21, 4, 30, tzinfo=_WrongOffsetTimezone()), + ], +) +def test_rejects_unusable_recorded_time_provider(known_at: datetime) -> None: + """Unknown, malformed, failing, and overflowing timezone providers fail closed.""" + with pytest.raises(IntervalError, match="knowledge cutoff"): + snapshot(known_at=known_at) + + +def test_canonical_json_rejects_low_level_temporal_reinjection() -> None: + """Even an unsafe post-construction mutation cannot invoke forged time renderers.""" + effective_time_evidence = snapshot() + object.__setattr__(effective_time_evidence, "effective_on", ForgedDate(2026, 8, 21)) + with pytest.raises(IntervalError, match="not canonical"): + effective_time_evidence.canonical_json() + + recorded_time_evidence = snapshot() + object.__setattr__( + recorded_time_evidence, + "known_at", + ForgedDateTime(2026, 8, 21, 4, 30, tzinfo=timezone.utc), + ) + with pytest.raises(IntervalError, match="not canonical"): + recorded_time_evidence.canonical_json() + + +def test_snapshot_detaches_mutable_status_count_containers() -> None: + """Caller-owned list mutation cannot alter aggregate evidence after construction.""" + status_counts = [["active", 1]] + evidence = snapshot(employment_status_counts=status_counts) + canonical = evidence.canonical_json() + digest = evidence.content_digest() + + status_counts[0][1] = 99 + status_counts.append(["leave", 0]) + + assert evidence.employment_status_counts == (("active", 1),) + assert evidence.canonical_json() == canonical + assert evidence.content_digest() == digest + + +@pytest.mark.parametrize( + "tenant_record_id", + [ + "not-a-tenant-uuid", + UUID(int=0), + UUID(int=(1 << 128) - 1), + ], +) +def test_rejects_non_operational_tenant_identity(tenant_record_id: object) -> None: + """Canonical snapshots must not publish malformed or sentinel tenant evidence.""" + with pytest.raises(IdentityScopeError, match="canonical operational UUID"): + snapshot(tenant_record_id=tenant_record_id)