Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/adr/0152-purpose-bound-position-history-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# ADR 0152: Purpose-bound bitemporal Position history read

- **Status:** Accepted for active PR #152; not protected-main truth until integrated
- **Date:** 2026-08-30
- **Owners:** Orgmetra People domain

## Context

Protected `develop` already models `position_record` separately from `job_profile`, `assignment_record`, and their time-varying facts. It also stores Position versions with business-effective (`effective_from`, `effective_to`) and system-recorded (`recorded_from`, `recorded_to`) intervals. A commercial HRIS still needs a buyer-visible way to inspect how one Position was understood over time without widening the read to Person, Assignment, compensation, candidate, or employment-decision data.

A Position-history read is a high-value governance boundary because historical workforce interpretation is frequently used in reorganizations, audit, workforce planning, and downstream validity analysis. Returning persistence rows directly would make authorization order, tenant scope, bitemporal interpretation, and field minimization depend on adapter behavior instead of the domain contract.

## Decision

Orgmetra adds a read-only `position_history` application boundary in the People service.

1. The caller supplies an exact operational tenant UUID, an exact operational Position UUID, an exact built-in UTC knowledge instant, a declared purpose, and an explicit requested-field set.
2. Purpose-bound authorization is evaluated **before** the injected read port may retrieve protected Position history.
3. The persistence adapter returns immutable `PositionHistoryRecord` values. Application code treats those values as untrusted evidence and revalidates exact row shape, primitive types, tenant and Position identity, system-time visibility, version uniqueness, and business-effective non-overlap.
4. System-recorded intervals are interpreted as half-open intervals: `recorded_from <= known_at < recorded_to`, with an absent `recorded_to` meaning open-ended visibility.
5. Business-effective intervals are also half-open. Two versions visible at the same knowledge instant may not claim overlapping business truth for the same Position. An absent business end is represented as **unbounded**, not by substituting a finite date sentinel such as `date.max`; this preserves overlap semantics even when a valid interval begins on Python's maximum representable date.
6. The response is deterministic and contains only fields explicitly authorized by the purpose-bound policy. Unknown fields and `str` subclasses fail closed rather than reaching reflection-based serialization.
7. `position_record_version_id`, organization lineage, Job lineage, status, business-effective dates, and system-recorded timestamps remain distinct concepts. The read does not collapse Job, Position, or Assignment.
8. The application boundary depends on an injected port. It does not query another service's application tables and does not introduce cross-service SQL.

## Trust and time semantics

The service accepts exact built-in UUID/date/datetime/timezone primitives at the trust boundary. Caller-controlled subclasses and timezone implementations are rejected. This prevents user-defined equality, hashing, formatting, or UTC-offset behavior from participating in authorization, chronology, or evidence serialization.

`known_at` is system-recorded time, not business-effective time. A version may be visible at `known_at` while describing a past or future business-effective period. These dimensions must never be substituted for one another.

Open-ended business time is a semantic infinity, not the largest finite date representable by one runtime. Overlap therefore uses direct endpoint-presence logic: a left interval is before a right end when the right end is absent or the left start is strictly earlier, and conversely for the right interval. This keeps half-open interval algebra correct at representational extremes and avoids treating `[date.max, ∞)` as empty.

## Data-model boundary

This ADR does not change protected-main storage. The existing schema remains authoritative:

- `job_profile` describes the reusable Job/work content.
- `position_record` is the tenant-owned Position anchor in an organization and references the Job profile.
- `position_record_version` carries Position status and business-effective/system-recorded version evidence.
- `assignment_record` links a worker/employment relationship to a Position and remains a separate lifecycle fact.

An adapter that materializes Position history must preserve those meanings and may not use the new view to imply that Assignment or Person history is part of a Position version.

## Consequences

### Positive

- HR operators can inspect Position history without broad Person/Assignment disclosure.
- Authorization-before-retrieval is executable and testable.
- Bitemporal contradictions fail closed at the service boundary, including valid extreme-date intervals whose end is genuinely unbounded.
- The module is standalone and can be extracted behind a service/API boundary later without rewriting its authorization and evidence semantics.
- Exact owned statement/branch coverage can be enforced independently of a future database adapter.

### Trade-offs

- The read port must deliberately materialize data that the application can validate; adapters cannot return arbitrary ORM entities.
- A database adapter must provide a transactionally coherent snapshot. The application checks cannot replace MVCC/snapshot isolation where concurrent database writes are possible.
- This slice exposes no HTTP route or write mutation. Those are separate bounded decisions and must not be inferred from this ADR.

## Verification

PR #152 records a hosted test-first sequence. A test-only head failed because the production Position-history module did not exist. The smallest application implementation then satisfied the behavioral contract, after which a remaining 100%-coverage branch for malformed low-level row reconstruction was covered with an explicit fail-closed regression rather than by excluding code or weakening the gate.

A later source sweep found that open-ended business intervals were approximated with `date.max` during overlap checks. Test-only head `af8d0b9b88c50f17c87eb8ecf1eea29918835dce` produced genuine hosted RED in People API Quality run `33267978859`, job `99141335635`: 157 existing tests passed, exact owned coverage remained 100%, but the new extreme-date regression failed because `[date.max, ∞)` was incorrectly treated as non-overlapping with an earlier open interval. Root repair `955956f838c467c06c25b63127b7c6e976dea812` removes the finite-infinity sentinel and compares optional interval ends directly.

The PR remains Draft until the exact current head has fresh applicable local/central evidence and qualifying independent review. Evidence from predecessor heads is non-transferable.
33 changes: 33 additions & 0 deletions docs/doctoring/position-history-read-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Position history read references

**Scope:** Research and standards basis for active PR #152. This file does not claim certification or protected-main integration.

The Position-history contract uses established temporal/database and security-control concepts rather than creating Orgmetra-specific substitutes for them. Implementation details remain constrained by the actual protected-main schema and executable tests.

## APA 7 references

Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53, Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5

Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). RFC Editor. https://doi.org/10.17487/RFC3339

PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: 5.5. Constraints*. https://www.postgresql.org/docs/18/ddl-constraints.html

PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: 8.17. Range types*. https://www.postgresql.org/docs/18/rangetypes.html

## Decision relevance

### PostgreSQL range and exclusion semantics

The protected Orgmetra schema already uses database-level bitemporal constraints. PostgreSQL's range/exclusion facilities provide the primary technical basis for treating effective intervals as non-overlapping business truth where the schema requires it. The application read boundary does not replace those constraints; it independently rejects contradictory adapter output before buyer-visible serialization.

### RFC 3339 timestamps

System-recorded evidence is serialized in one UTC RFC 3339 representation (`Z`). This is an interoperability/canonicalization choice. The service separately validates that trust-bearing input is an exact built-in UTC datetime rather than accepting arbitrary caller-controlled timezone implementations that merely produce a zero offset.

### NIST SP 800-53 Rev. 5

The read boundary is designed toward evidence-ready access-control, least-privilege, auditability, and system/information-integrity practices. The design does not claim NIST compliance, SOC 2 certification, CSAP certification, or any external attestation. Purpose-bound authorization and field minimization are product controls whose effectiveness must remain demonstrable through exact-current executable evidence.

## Research classification

These references inform accepted architecture for PR #152. They do not authorize scope expansion into Person, Assignment, compensation, candidate, performance, or employment-decision data, and they do not supersede dedicated-writer dependency contracts.
69 changes: 69 additions & 0 deletions docs/traceability/position-history-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Position history read traceability

**Lifecycle status:** Active PR #152. This document describes the PR contract, not protected-main truth until integration.

## Buyer outcome

An authorized HR operator can inspect the bitemporal history of one Position for a declared workforce purpose without receiving unrelated Person, Employment, Assignment, candidate, compensation, or decision data.

## Protected-main prerequisites

Protected `develop` already provides the authoritative data-model separation needed by this slice:

| Concern | Protected-main truth used by #152 |
| --- | --- |
| Job | `job_profile` remains reusable Job/work content. |
| Position | `position_record` remains a tenant-owned Position anchor with organization and Job lineage. |
| Position version | `position_record_version` preserves business-effective and system-recorded time. |
| Assignment | `assignment_record` remains distinct from Position and links the worker/employment relationship to a Position. |
| Authorization | People service uses purpose-bound policy evaluation before protected reads. |

#152 does not add a database migration, mutate those tables, or create cross-service application-table SQL.

## Requirement-to-evidence matrix

| Requirement | Implementation boundary | Executable evidence |
| --- | --- | --- |
| Authorize before retrieval | `read_position_history()` calls the purpose-bound authorization boundary before `PositionHistoryReadPort` | denied fields prove the port is never called |
| Tenant/context isolation | exact tenant and Position are rechecked on every returned row | wrong-tenant and wrong-Position rows fail closed |
| Bitemporal system truth | half-open `recorded_from`/`recorded_to` at exact UTC `known_at` | future and already-closed rows fail closed |
| Business-time consistency | visible half-open effective intervals may not overlap; absent ends remain semantically unbounded rather than mapped to `date.max` | ordinary-overlap and `date.max` open-interval regressions fail closed |
| Immutable evidence | exact tuple container and exact `PositionHistoryRecord`; runtime revalidation after low-level reconstruction | unsupported container/type, forged values, and short low-level row all fail closed |
| Opaque identifiers | exact operational UUIDs; nil/max protocol sentinels and subclasses rejected | invalid request/record regressions |
| Field minimization | explicit serializer whitelist over authorized fields only | one-field policy returns one field; unknown/subclass fields fail closed |
| Deterministic history | sort by effective start then version identity | reversed persistence order produces deterministic output |
| Job/Position/Assignment separation | view contains Position/Job lineage only; no worker/Assignment expansion | schema and response contract |
| Exact owned coverage | People API quality workflow | 100% statement and branch gate on exact current head required |

## Test-first chain

1. **Initial test-only head:** `d751f117e37e2169015004ab89fa728731b2a7ec`.
2. **Initial hosted RED:** People API Quality run `33267334677`, job `99139623454`, failed during collection because `orgmetra_people_api.position_history` did not exist.
3. **Root implementation:** `f633aa3d008d7832759bb83dead8d4e5a6977a8b` added the smallest Orgmetra-owned read boundary.
4. **Coverage gate held:** run `33267487363`, job `99140037925`, passed all 156 tests but correctly failed exact coverage because one deliberate malformed-row branch remained unexecuted.
5. **Regression strengthening:** `cbb343a40864694ac243946615aee5f91685beda` added a low-level short-row reconstruction regression.
6. **Exact GREEN at that predecessor:** People API Quality run `33267577477`, job `99140279359`: 157 tests; 1,543/1,543 statements; 504/504 branches; compile and clean-checkout GREEN.
7. **Extreme-date integrity RED:** source review found that `_business_intervals_overlap()` substituted finite `date.max` for an absent business end. Test-only head `af8d0b9b88c50f17c87eb8ecf1eea29918835dce` added a valid `[date.max, ∞)` overlap case. People API Quality run `33267978859`, job `99141335635`, checked out that exact SHA and failed exactly that regression: **1 failed / 157 passed** while owned production coverage remained **1,544/1,544 statements and 504/504 branches = 100.00%**. The service returned instead of raising `PositionHistoryIntegrityError`, proving a real business-time integrity defect rather than a coverage artifact.
8. **Extreme-date root repair:** `955956f838c467c06c25b63127b7c6e976dea812` removes the finite-infinity sentinel and compares optional interval endpoints directly. Open-ended intervals therefore remain unbounded even at the maximum representable finite date.

Documentation commits after the root repair invalidate predecessor GREEN as merge evidence. The final exact PR head must receive its own fresh hosted evidence before advancement.

## Security/privacy invariants

- No PII is added to the Position-history response merely because it exists elsewhere in HRIS.
- No dynamic attribute access is used to serialize policy-controlled field names.
- Caller-controlled UUID/string/timezone subclasses do not participate in identity, authorization, chronology, or output canonicalization.
- Persistence is an injected boundary and its output is revalidated.
- Open-ended business-time semantics are represented explicitly; runtime maximum dates are never overloaded as infinity.
- Application checks do not claim to replace database snapshot/MVCC semantics for concurrent writes.

## Out of scope / planned separately

- Position-history HTTP presentation.
- Position mutation/correction workflow.
- Assignment or Employment history joins.
- Compensation, candidate, performance, or selection-decision expansion.
- Database-specific Position-history adapter and its transaction-isolation proof.
- Release/version/tag publication.

Any later slice must keep these concerns bounded and must not infer protected-main availability from this active-PR traceability document.
14 changes: 14 additions & 0 deletions services/people-api/src/orgmetra_people_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,23 @@
WorkerPeopleRecord,
read_worker_people_record,
)
from orgmetra_people_api.position_history import (
AuthorizedPositionHistoryEntry,
AuthorizedPositionHistoryView,
PositionHistoryIntegrityError,
PositionHistoryReadPort,
PositionHistoryRecord,
read_position_history,
)
from orgmetra_people_api.postgres import PostgresPeopleReadPort
from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort
from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort

__all__ = [
"AuthenticatedPrincipal",
"AuthenticationFailed",
"AuthorizedPositionHistoryEntry",
"AuthorizedPositionHistoryView",
"AuthorizedWorkerPeopleView",
"HireAcceptanceCommand",
"HireAcceptancePort",
Expand All @@ -62,6 +72,9 @@
"PeopleReadPort",
"PeopleRecordIntegrityError",
"PeopleRecordNotFound",
"PositionHistoryIntegrityError",
"PositionHistoryReadPort",
"PositionHistoryRecord",
"PositionMutationCommand",
"PositionMutationResult",
"PostgresHireAcceptancePort",
Expand All @@ -79,5 +92,6 @@
"create_employment_record",
"create_position_record",
"extract_bearer_token",
"read_position_history",
"read_worker_people_record",
]
Loading
Loading