Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
23c3417
test(people): define employment history read contract
seonghobae Aug 28, 2026
3598a08
feat(people): implement purpose-bound Employment history read
seonghobae Aug 28, 2026
9aa41e0
feat(people): export Employment history read contract
seonghobae Aug 28, 2026
5c7bf77
docs(adr): govern Employment history read boundary
seonghobae Aug 28, 2026
cee6f8d
docs(doctoring): record Employment history read references
seonghobae Aug 28, 2026
c148d51
docs(traceability): bind Employment history read evidence
seonghobae Aug 28, 2026
5cdbeb2
test(people): expose Employment history alias TOCTOU
seonghobae Aug 29, 2026
45b4ff5
fix(people): detach Employment history persistence aliases
seonghobae Aug 29, 2026
14a09cf
docs(adr): record Employment history alias integrity boundary
seonghobae Aug 29, 2026
31028fb
docs(traceability): bind Employment history alias regression
seonghobae Aug 29, 2026
89bbe50
test(people): use one employment history import style
seonghobae Aug 29, 2026
6eb105d
test(people): reject torn employment history snapshots
seonghobae Aug 29, 2026
4dfbd2a
fix(people): fail closed on torn history snapshots
seonghobae Aug 29, 2026
26aa273
docs(test): state stable snapshot boundary
seonghobae Aug 29, 2026
36bcc80
docs(people): record stable history capture
seonghobae Aug 29, 2026
32022b4
docs(people): trace torn snapshot regression
seonghobae Aug 29, 2026
c07ce7b
test(people): require structural employment row immutability
seonghobae Aug 29, 2026
6ef636c
fix(people): make employment rows structurally immutable
seonghobae Aug 29, 2026
7543d97
test(people): cover immutable employment row boundary
seonghobae Aug 29, 2026
fdecda5
test(people): adapt employment integrity checks to tuple storage
seonghobae Aug 29, 2026
d71dced
docs(people): record structural employment evidence immutability
seonghobae Aug 29, 2026
44c8312
docs(traceability): record employment immutability RED repair
seonghobae Aug 29, 2026
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
34 changes: 34 additions & 0 deletions docs/adr/0149-employee-profile-employment-history-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# ADR 0149: Purpose-bound employee Employment-history read

- **Status:** Accepted on active PR #149; not protected-main truth until integrated.
- **Date:** 2026-08-29
- **Owners:** Orgmetra People API / HRIS core
- **Extends:** ADR 0003 (bitemporal HRIS data), ADR 0008 (purpose-bound PII authorization)

## Decision

The employee profile reads Employment history through a read-only People API service boundary that authorizes the exact tenant, Person, purpose, operation, and requested field set **before** calling the injected persistence port. The persistence adapter remains a separate port; this slice does not create a second Employment source of truth and does not introduce cross-service application-table SQL.

Each persistence row carries a durable Employment identity, a durable Employment-version identity, controlled Employment status and concurrency codes, business-effective (`effective_from`, `effective_to`) coordinates, and system-recorded (`recorded_from`, `recorded_to`) coordinates. `known_at` selects the half-open recorded interval `[recorded_from, recorded_to)`. Trust-bearing system instants require an exact built-in `datetime` using Python's built-in fixed-offset `timezone` at zero offset so validation and canonical rendering cannot depend on mutable caller-defined timezone behavior.

Persistence output is untrusted. Exact tuple and row types are only shape checks, so the accepted `EmploymentHistoryRecord` itself uses tuple-backed immutable storage: a persistence adapter retaining the returned row cannot rewrite its fields in place through ordinary assignment or `object.__setattr__`. The service still reconstructs every accepted persistence row through the public validating constructor before tenant/Person scope, system-time visibility, version uniqueness, business-time overlap checks, deterministic sorting, or authorized field emission. That reconstruction remains necessary because low-level tuple construction can bypass the public validating constructor; forged or malformed exact-type rows therefore fail closed at runtime integrity validation rather than becoming authorized output.

Structural in-process immutability is not a substitute for a transactional database snapshot, MVCC, row/version locking, or the persistence adapter's own consistency guarantees. A future database adapter remains responsible for returning one transactionally coherent bitemporal view at the requested knowledge cutoff.

Only policy-authorized fields are emitted. Employment identity and version identity are not unconditional response-envelope fields; a caller authorized only for status receives status only. This preserves field minimization and prevents identifiers from becoming a side channel around purpose-bound authorization.

## Security and privacy consequences

Authorization is resource-centric and per request. NIST SP 800-207 and SP 800-207A support resource/service authorization decisions independent of network location; Orgmetra applies that principle through the existing Keyverse adapter contract rather than embedding another policy engine.

The persistence-alias boundary is treated as a local integrity concern rather than as evidence that the persistence adapter is malicious. Structurally immutable row storage closes the in-process validation-to-use alias rewrite path at the object boundary, while service-owned reconstruction preserves fail-closed validation even for deliberately forged low-level tuple instances. This does not add cross-service locks or weaken field minimization.

The read boundary does not infer attendance, availability, fitness, compensation, performance, candidate status, or employment-decision authority. It exposes only authoritative Employment facts already permitted by policy for the requested Person and system-time cutoff.

## Data consequences

This ADR preserves the normalized distinction among Person, Employment, Organization, Job, Position, and Assignment. It changes no database schema. A future PostgreSQL adapter must remain tenant-scoped and RLS-governed and must read only Orgmetra-owned Employment tables through the People service boundary.

## Verification

PR #149 must demonstrate authorization-before-retrieval, tenant/Person isolation, half-open system-time visibility, controlled codes, exact UUID/time validation, field minimization, schema/type drift failure, structural resistance to retained-alias rewriting, fail-closed revalidation of low-level forged exact-type rows, duplicate-version rejection, business-time overlap rejection, deterministic ordering, exact 100% owned People API statement/branch coverage, and all applicable repository/security/central gates before integration.
21 changes: 21 additions & 0 deletions docs/doctoring/employee-profile-employment-history-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Employee profile Employment-history read — primary references

## Current authoritative references

National Institute of Standards and Technology. (2020). *Zero trust architecture* (NIST Special Publication 800-207). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-207

National Institute of Standards and Technology. (2023). *A zero trust architecture model for access control in cloud-native applications in multi-cloud environments* (NIST Special Publication 800-207A). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-207A

PostgreSQL Global Development Group. (2025). *PostgreSQL 18 documentation: Date/time types*. https://www.postgresql.org/docs/18/datatype-datetime.html

## Why these sources matter to PR #149

NIST SP 800-207 treats access to enterprise resources as an explicit authentication/authorization decision rather than an implicit consequence of network location. SP 800-207A extends granular identity-based enforcement to application and service boundaries. PR #149 applies that principle narrowly by authorizing the exact tenant, Person, purpose, operation, and requested Employment-history fields before protected retrieval.

PostgreSQL 18's date/time semantics support the repository's existing separation of business dates from timezone-aware system-recorded instants. The PR does not claim that PostgreSQL prescribes Orgmetra's bitemporal domain model; ADR 0003 remains the product architecture authority.

These references support the authorization and temporal representation boundaries only. They do not establish NIST certification, PostgreSQL conformance certification, or authority to infer attendance, fitness, compensation, performance, or an employment decision from Employment history.

## Review date

Rechecked as current final primary references on 2026-08-29. Re-review if NIST publishes a superseding final zero-trust application authorization specification or the protected repository changes its supported PostgreSQL major version.
49 changes: 49 additions & 0 deletions docs/traceability/employee-profile-employment-history-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Employee profile Employment-history read traceability

## Product requirement

Protected Orgmetra planning requires an authoritative bitemporal HRIS core and buyer-readable employee history. Protected `develop` already stores `employment_record` and `employment_record_version` truth and exposes governed People reads, but before PR #149 it has no purpose-bound service contract for returning Employment history at an explicit system knowledge cutoff.

## Protected-main truth consumed

- `database/migrations/0001_foundation_schema.sql` separates `employment_record` identity from bitemporal `employment_record_version` business/system truth.
- `services/people-api/src/orgmetra_people_api/authorization.py` delegates protected-field authorization to the integrated purpose-bound Keyverse adapter contract.
- `services/people-api/src/orgmetra_people_api/people.py` establishes authorization-before-protected-read and target-scope revalidation.
- `services/people-api/src/orgmetra_people_api/mutations.py` defines current controlled Employment statuses (`active`, `leave`, `terminated`) and concurrency codes (`exclusive`, `concurrent`).
- `.github/workflows/people-api-quality.yml` requires exact 100% owned People API statement and branch coverage.

## PR #149 active implementation

| Requirement | Production boundary | Regression evidence |
| --- | --- | --- |
| Authorize before protected retrieval | `read_employment_history()` calls `authorize_resource_fields()` before `EmploymentHistoryReadPort` | denied-field test requires zero port calls |
| Preserve business/system time separately | `EmploymentHistoryRecord.effective_*` and `.recorded_*` | deterministic history and recorded-cutoff tests |
| Tenant/Person isolation | service revalidates every service-owned reconstruction | other-tenant and other-Person rows fail closed |
| Half-open system visibility | `[recorded_from, recorded_to)` at exact `known_at` | future-recorded and `recorded_to == known_at` rows fail closed |
| Controlled Employment semantics | exact built-in status/concurrency codes | unknown and string-subclass codes fail closed |
| Field minimization | output built only from `decision.authorized_fields` | status-only grant leaks no Employment identity |
| No reflective schema expansion | explicit supported-field encoder requires exact built-in `str` | unknown and string-subclass fields fail closed |
| Persistence runtime integrity | exact tuple + exact row type + validating service-owned reconstruction | mutable container, unsupported row, and low-level forged exact-type regressions |
| Validation-to-use alias integrity | `EmploymentHistoryRecord` uses tuple-backed immutable storage and `_snapshot_persistence_record()` reconstructs before use | `object.__setattr__` rewrite attempts fail at the row boundary; forged low-level tuple instances fail runtime integrity |
| Version integrity | unique `employment_record_version_id` per response snapshot | duplicate version identity fails closed |
| Bitemporal business integrity | visible snapshots of one Employment cannot overlap effective time | overlapping intervals fail closed; adjacent intervals remain valid |
| Deterministic history | sort by effective start, Employment UUID, version UUID | reversed persistence order returns canonical order |
| Trust-bearing identity/time integrity | exact operational UUIDs and built-in UTC instants | sentinel/subclass UUID and malformed system time fail before protected retrieval or row use |

## Scope exclusions

PR #149 does not create/update/delete Employment, alter schema, expose a PostgreSQL adapter, add UI geometry, infer attendance/fitness/compensation/performance, or authorize an employment decision. It does not mutate Keyverse or any other dedicated-writer repository. A future persistence adapter and employee-profile UI must reuse this contract instead of bypassing the People service.

The in-process row object is structurally immutable, and the service revalidates a detached reconstruction before authorization output. This does not claim to replace database transaction isolation, MVCC, locks, or a persistence adapter's obligation to return one coherent view.

## Test-first evidence rule

Contract head `23c3417edd7024ecc4c1c64f2d7017b573ab9eaf` added the original executable regression before production `employment_history.py` existed. Hosted execution for that predecessor was queued when the implementation branch advanced, so queued/cancelled predecessor evidence is **not** represented as a terminal RED. The contract-first source ordering remains auditable in Git history.

A later integrity review identified a second, narrower validation-to-use defect: the service revalidated the exact persistence-owned `EmploymentHistoryRecord` and then retained that same object for overlap checks and authorized encoding. Because `object.__setattr__` can rewrite a frozen dataclass through an alias, a holder of the persistence row could change an already-validated value before use. Exact head `5cdbeb2028a49bd0277159a03042c5d95dd2a06d` added the realistic alias-rewrite regression before the root repair; the production repair begins at `45b4ff5ec9fb065a665e1fe51bc2120d46cdc62a` by reconstructing a service-owned validated snapshot and discarding the persistence alias for subsequent decisions and output.

A third integrity review identified a capture-window defect in that repair: one sequential reconstruction could read an old value for one field and a concurrently rewritten value for a later field, producing a valid-looking service-owned row that never existed as one source state. Exact head `6eb105d6310adbdb9e33f64fab4cd450a9681968` added `test_alias_rewrite_during_snapshot_cannot_create_torn_authorized_row` before the production change. Its workflows were still queued when the branch advanced, so no terminal RED is claimed. The prior repair beginning at `4dfbd2a9f32947e5c1c61d6eccee47b57781dc92` required two consecutive validated captures to compare equal.

A fourth integrity review identified the remaining root weakness: the double-capture guard still accepted a record type whose storage itself could be rewritten through `object.__setattr__`, leaving correctness dependent on detecting mutation after the fact. Exact test-only head `c07ce7baf738679e1ef5cbef1d98760fefe670e3` added `test_persistence_record_is_structurally_immutable_against_object_setattr`. People API Quality run `33257244737`, exact checkout job `99113016031`, produced a genuine terminal RED: 1 failed / 159 passed, with the new test failing because `object.__setattr__` did **not** raise; owned production coverage remained 1524/1524 statements and 508/508 branches. The root repair begins at `6ef636cdf803ef3195f80db089f1ee432e0d7646`: `EmploymentHistoryRecord` moves to tuple-backed immutable storage, while service-owned reconstruction continues to validate low-level exact-type instances that bypass the public constructor.

Only tests/checks bound to the final unchanged PR #149 head are passing integration evidence. Queued, pending, skipped, cancelled, absent, predecessor-head, status-only, or model-only evidence is non-passing, and another PR's checks/reviews never transfer.
24 changes: 19 additions & 5 deletions services/people-api/src/orgmetra_people_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
extract_bearer_token,
)
from orgmetra_people_api.authorization import authorize_resource_fields
from orgmetra_people_api.employment_history import (
AuthorizedEmploymentHistoryEntry,
AuthorizedEmploymentHistoryView,
EmploymentHistoryIntegrityError,
EmploymentHistoryReadPort,
EmploymentHistoryRecord,
read_employment_history,
)
from orgmetra_people_api.hire import (
HireAcceptanceCommand,
HireAcceptancePort,
Expand Down Expand Up @@ -45,13 +53,22 @@
from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort

__all__ = [
"AssignmentMutationCommand",
"AssignmentMutationResult",
"AuthenticatedPrincipal",
"AuthenticationFailed",
"AuthorizedEmploymentHistoryEntry",
"AuthorizedEmploymentHistoryView",
"AuthorizedWorkerPeopleView",
"EmploymentHistoryIntegrityError",
"EmploymentHistoryReadPort",
"EmploymentHistoryRecord",
"EmploymentMutationCommand",
"EmploymentMutationResult",
"HireAcceptanceAsgiApp",
"HireAcceptanceCommand",
"HireAcceptancePort",
"HireAcceptanceResult",
"HireAcceptanceAsgiApp",
"HireDecisionIntegrityError",
"HireDecisionNotFound",
"PeopleAsgiApp",
Expand All @@ -67,10 +84,6 @@
"PostgresHireAcceptancePort",
"PostgresPeopleMutationPort",
"PostgresPeopleReadPort",
"AssignmentMutationCommand",
"AssignmentMutationResult",
"EmploymentMutationCommand",
"EmploymentMutationResult",
"TokenAuthenticator",
"WorkerPeopleRecord",
"accept_confirmed_hire",
Expand All @@ -79,5 +92,6 @@
"create_employment_record",
"create_position_record",
"extract_bearer_token",
"read_employment_history",
"read_worker_people_record",
]
Loading
Loading