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
60 changes: 60 additions & 0 deletions .github/workflows/assignment-history-postgres-read-quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Assignment History PostgreSQL Read Quality

on:
pull_request:
branches:
- develop
- feat/employee-profile-assignment-history-read
paths:
- "services/people-api/**"
- "packages/hris-kernel/**"
- "packages/keyverse-adapter/**"
- ".github/requirements/foundation-test.txt"
- ".github/workflows/assignment-history-postgres-read-quality.yml"
- "docs/adr/0148-postgres-assignment-history-read.md"
- "docs/doctoring/assignment-history-postgres-read-references.md"
- "docs/traceability/assignment-history-postgres-read.md"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: assignment-history-postgres-read-quality-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
unit:
name: PostgreSQL assignment-history read contract
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout exact candidate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Prove exact candidate checkout
env:
ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA"
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
check-latest: false
- name: Install reviewed test toolchain
run: |
python -m pip install --require-hashes --no-deps --only-binary=:all: -r .github/requirements/foundation-test.txt
python -m pip check
- name: Compile People API boundary
run: python -m compileall -q services/people-api/src packages/hris-kernel/src packages/keyverse-adapter/src services/people-api/tests
- name: Test governed People contracts with exact statement and branch coverage
env:
PYTHONPATH: services/people-api/src:packages/hris-kernel/src:packages/keyverse-adapter/src
COVERAGE_FILE: /tmp/orgmetra-assignment-history-postgres-read.coverage
run: python -m pytest -c services/people-api/pyproject.toml services/people-api/tests
- name: Require clean checkout
run: |
git diff --exit-code
test -z "$(git status --porcelain)"
62 changes: 62 additions & 0 deletions docs/adr/0148-postgres-assignment-history-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# ADR 0148: Read Assignment history from canonical PostgreSQL truth

- **Status:** Proposed on active stacked PR #148; not protected-main truth until integrated.
- **Date:** 2026-08-29
- **Owners:** Orgmetra People API / HRIS persistence
- **Extends:** ADR 0003 (bitemporal HRIS data), ADR 0008 (purpose-bound PII authorization), ADR 0142 (Assignment-history read contract)

## Context

PR #142 defines the buyer-facing, purpose-bound employee Assignment-history read but intentionally injects its persistence port. Leaving that port without a canonical adapter means an integrated application still cannot obtain historical Assignment truth from Orgmetra's normalized `assignment_record` relation without supplying bespoke persistence code.

The adapter must not become a second authorization engine or a second source of truth. Purpose-bound authorization remains in the parent People service and runs before this adapter is called. The database already owns tenant-scoped Assignment facts and row-level-security policy; the adapter therefore needs a narrow read-only transaction, explicit tenant context, explicit target predicates, and a value-minimized projection.

PostgreSQL 18 documents `READ ONLY` as a transaction access mode that rejects ordinary table-changing statements. PostgreSQL row-security documentation also makes clear that row policies control which rows are visible to a query, while `FORCE ROW LEVEL SECURITY` applies those policies even to the table owner. Orgmetra uses those controls as defense in depth, not as a substitute for application authorization or explicit SQL scope.

## Decision

Add `PostgresAssignmentHistoryReadPort` as the canonical PostgreSQL implementation of the `AssignmentHistoryReadPort` protocol introduced by PR #142.

The adapter:

1. validates exact operational tenant/person UUIDs and an exact built-in UTC `known_at` before acquiring a connection;
2. opens a `READ COMMITTED, READ ONLY` transaction because one SQL statement is sufficient to reconstruct the requested recorded-time view;
3. sets transaction-local `orgmetra.tenant_record_id` before the protected query so existing forced RLS remains active as defense in depth;
4. queries only `public.assignment_record`, with explicit tenant, person, and half-open system-recorded predicates;
5. returns the full business-effective Assignment history visible at that system-knowledge cutoff rather than incorrectly filtering to one business date;
6. projects `recorded_from`/`recorded_to` through `AT TIME ZONE 'UTC'`, then attaches Python's built-in UTC timezone only after verifying PostgreSQL returned exact naive `datetime` values;
7. selects only the fields required by `AssignmentHistoryRecord` and never joins names, contacts, compensation, ratings, assessments, candidate data, credentials, prompts, or model output;
8. treats DB-API output as untrusted, revalidating row shape, canonical parent-record integrity, exact tenant/person scope, and half-open recorded-time visibility before returning an immutable tuple.

The parent service remains responsible for purpose-bound field authorization and for revalidating the returned records before disclosure. The adapter performs no mutation, audit/outbox write, cross-service SQL, candidate/worker inference, employment decision, or foreign-service call.

## Consequences

### Positive

- The P1 employee-profile Assignment-history contract can use Orgmetra's canonical normalized database without bespoke host persistence code.
- Read-only transaction mode and forced-RLS tenant context narrow database authority while explicit predicates make the intended scope reviewable in source.
- Business-effective time and system-recorded time remain separate; a `known_at` query cannot silently become a current-business-date query.
- UTC projection is deterministic and detached from driver/session timezone behavior before trust-bearing records reach the service layer.
- Parent purpose-bound authorization remains the only disclosure authority, avoiding duplicated policy engines.

### Trade-offs

- This adapter is PostgreSQL/DB-API specific and intentionally expects the default tuple-row contract rather than supporting arbitrary row factories.
- RLS configuration still requires independent database migration/role tests; this adapter does not claim that a SQL predicate alone proves tenant isolation.
- The adapter returns historical Assignment identifiers and relationships only to the parent service; whether any particular field is disclosed remains an authorization decision outside this adapter.

## Verification

PR #148 must preserve a genuine contract-first RED at the missing production-module boundary and then demonstrate, on one exact current head:

- exact 100% statement and branch coverage of the owned adapter;
- zero DB connection acquisition for invalid tenant/person/time inputs;
- read-only transaction mode and transaction-local tenant context before the protected query;
- explicit tenant/person/half-open recorded-time predicates and deterministic ordering;
- UTC projection and rejection of noncanonical DB timestamps;
- untrusted row shape, parent-record integrity, tenant/person, and recorded-visibility failure modes;
- immutable empty/non-empty results;
- clean checkout after focused tests.

Parent #142 must integrate first. Its checks and reviews do not transfer to this child.
22 changes: 22 additions & 0 deletions docs/doctoring/assignment-history-postgres-read-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# PostgreSQL Assignment-history read references

Status: active stacked-PR research evidence for ADR 0148. These sources support transaction and row-security design decisions; they do not establish PostgreSQL, security, privacy, SOC 2, CSAP, or other certification for Orgmetra.

## APA 7 references

PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/18/sql-set-transaction.html

PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: ALTER TABLE*. https://www.postgresql.org/docs/18/sql-altertable.html

PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html

## Applied boundary

- `SET TRANSACTION ... READ ONLY` is used as a database-side guard against ordinary data-changing statements during Assignment-history reads. The adapter performs one scoped SELECT, so `READ COMMITTED` provides the needed statement snapshot without claiming serializable business semantics.
- Existing Orgmetra database migrations own row-level-security enablement, FORCE RLS, and tenant policies. The adapter sets the transaction-local tenant context before querying and also uses explicit tenant/person predicates; neither mechanism is treated as a substitute for the parent People service's purpose-bound authorization.
- `FORCE ROW LEVEL SECURITY` is relevant because PostgreSQL otherwise permits table owners to bypass their own row policies. ADR 0148 consumes the existing hardened database contract rather than altering policy ownership in this slice.
- The references do not authorize disclosure of Assignment fields. Exact field disclosure remains governed by PR #142's purpose-bound Keyverse authorization contract.

## Review date

Rechecked against the PostgreSQL 18 official documentation on 2026-08-29. Re-review if a later final major version materially changes read-only transaction or row-security behavior used by this adapter.
49 changes: 49 additions & 0 deletions docs/traceability/assignment-history-postgres-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Assignment-history PostgreSQL read traceability

## Status

Active stacked PR #148 only. This document does not claim protected-`develop` integration. Parent #142 remains the purpose-bound API contract and must integrate first.

## Buyer problem

PR #142 closes the P1 service-contract gap for **Employee profile with bitemporal assignment history**, but intentionally leaves persistence injected. Without a canonical adapter, an Orgmetra deployment still needs bespoke host code to obtain that history from the normalized `assignment_record` relation.

## Parent authority consumed

- `services/people-api/src/orgmetra_people_api/assignment_history.py` defines `AssignmentHistoryReadPort`, `AssignmentHistoryRecord`, purpose-bound authorization-before-read, field minimization, business/system-time separation, and post-persistence service revalidation.
- `database/migrations/0001_foundation_schema.sql` owns canonical `assignment_record` identity, Person/Employment/Position relationships, allocation, effective/business time, recorded/system time, bitemporal mutation guard, and tenant RLS contract.
- Parent #142 exact head at child creation: `d832006843111cc03751ec2bcd532df916bbc1e2`.

## Test-first evidence

Contract-only child head `55a287fe77631bfe9aa5c51d00f737431d3bc64c` contained the focused quality workflow and realistic adapter regressions while production `orgmetra_people_api.postgres_assignment_history` was absent.

Hosted **Assignment History PostgreSQL Read Quality** run `33198039662`, job `98940130089`, checked out and proved that exact SHA, installed the reviewed Python 3.14 toolchain, compiled the existing People boundary, and then failed during focused test collection with:

`ModuleNotFoundError: No module named 'orgmetra_people_api.postgres_assignment_history'`

This is the intended RED at the first Orgmetra-owned boundary. No predecessor/parent failure is being relabeled as RED evidence.

## Active implementation mapping

| Requirement | Production boundary | Regression |
| --- | --- | --- |
| No DB access on invalid target | exact tenant/person operational UUID and built-in UTC `known_at` validation before `connection_factory()` | parameterized invalid UUID/time cases assert zero connection calls |
| Database cannot mutate HR truth | `SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ ONLY` | SQL execution-order assertion |
| Existing forced RLS receives tenant context | transaction-local `pg_catalog.set_config('orgmetra.tenant_record_id', ..., true)` before SELECT | SQL execution-order and exact-parameter assertion |
| Explicit target scope | SELECT from `public.assignment_record` with exact tenant and person predicates | SQL contract assertions |
| Preserve system-knowledge semantics | `recorded_from <= known_at` and `(recorded_to IS NULL OR known_at < recorded_to)` | SQL contract plus future/closed-at-cutoff adversarial rows |
| Preserve full business history | no effective-date WHERE predicate; deterministic `effective_from, assignment_record_id` ordering | SQL contract and returned typed record assertions |
| Deterministic driver-independent UTC | PostgreSQL projects timestamps with `AT TIME ZONE 'UTC'`; adapter accepts only exact naive DB datetimes before attaching built-in UTC | aware/non-datetime DB timestamp regressions |
| Untrusted DB-API boundary | exact list result, exact tuple row shape, parent record integrity reconstruction | malformed container/row/value regressions |
| Defense-in-depth target check | reconstructed tenant/person must equal request even after SQL/RLS | foreign tenant/person row regressions |
| Immutable typed result | adapter returns tuple of `AssignmentHistoryRecord` | empty and non-empty result regressions |
| Public integration | `PostgresAssignmentHistoryReadPort` exported from People API package root | package export in active child |

## Privacy and authority boundary

The adapter does not accept a purpose code or authorization decision because it is not a disclosure boundary. The parent service authorizes first, then calls this adapter, then independently revalidates persistence evidence and emits only authorized fields. The adapter does not join names, contacts, compensation, assessments, ratings, candidate records, credentials, prompts, or model output. It performs no mutation, audit/outbox write, high-impact decision, or foreign-service call.

## Merge evidence rule

Only exact-current-head child evidence is applicable to #148. The RED head above proves the owning boundary only. Parent #142 checks/reviews and any predecessor child checks do not transfer. After #142 integrates, retarget #148 to fresh protected `develop`, reconcile parent/base changes, then rerun every applicable focused/People/Foundation/SAST/Security/Recovery/central gate before review readiness.
2 changes: 2 additions & 0 deletions services/people-api/src/orgmetra_people_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
read_worker_people_record,
)
from orgmetra_people_api.postgres import PostgresPeopleReadPort
from orgmetra_people_api.postgres_assignment_history import PostgresAssignmentHistoryReadPort
from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort
from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort

Expand Down Expand Up @@ -81,6 +82,7 @@
"PeopleRecordNotFound",
"PositionMutationCommand",
"PositionMutationResult",
"PostgresAssignmentHistoryReadPort",
"PostgresHireAcceptancePort",
"PostgresPeopleMutationPort",
"PostgresPeopleReadPort",
Expand Down
Loading