From d50a0e6a2987da7b9f5930656976aeb0e6032625 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:08:57 -0700 Subject: [PATCH 01/10] test: add assignment history postgres read quality gate --- ...signment-history-postgres-read-quality.yml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/assignment-history-postgres-read-quality.yml diff --git a/.github/workflows/assignment-history-postgres-read-quality.yml b/.github/workflows/assignment-history-postgres-read-quality.yml new file mode 100644 index 000000000..e2ebdde76 --- /dev/null +++ b/.github/workflows/assignment-history-postgres-read-quality.yml @@ -0,0 +1,67 @@ +name: Assignment History PostgreSQL Read Quality + +on: + pull_request: + branches: + - develop + - feat/employee-profile-assignment-history-read + paths: + - "services/people-api/src/orgmetra_people_api/assignment_history.py" + - "services/people-api/src/orgmetra_people_api/postgres_assignment_history.py" + - "services/people-api/src/orgmetra_people_api/__init__.py" + - "services/people-api/tests/test_postgres_assignment_history.py" + - ".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 assignment-history PostgreSQL boundary + run: python -m compileall -q services/people-api/src services/people-api/tests/test_postgres_assignment_history.py + - name: Test PostgreSQL assignment-history read 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 -q + services/people-api/tests/test_postgres_assignment_history.py + --cov=orgmetra_people_api.postgres_assignment_history + --cov-branch + --cov-report=term-missing + --cov-fail-under=100 + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" From 55a287fe77631bfe9aa5c51d00f737431d3bc64c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:09:34 -0700 Subject: [PATCH 02/10] test: define postgres assignment history read contract --- .../tests/test_postgres_assignment_history.py | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 services/people-api/tests/test_postgres_assignment_history.py diff --git a/services/people-api/tests/test_postgres_assignment_history.py b/services/people-api/tests/test_postgres_assignment_history.py new file mode 100644 index 000000000..6e2cb3fee --- /dev/null +++ b/services/people-api/tests/test_postgres_assignment_history.py @@ -0,0 +1,317 @@ +"""Regression contract for the PostgreSQL Assignment-history read adapter.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from datetime import date, datetime, timedelta, timezone, tzinfo +from decimal import Decimal +from typing import Any +from uuid import UUID + +import pytest + +from orgmetra_people_api.assignment_history import AssignmentHistoryIntegrityError +from orgmetra_people_api.postgres_assignment_history import PostgresAssignmentHistoryReadPort + + +TENANT_ID = UUID("018d51d2-9ab1-7ac0-8eb1-0a5dc487b9c1") +PERSON_ID = UUID("018d51d2-9ab1-7ac0-8eb1-0a5dc487b9c2") +ASSIGNMENT_ID = UUID("018d51d2-9ab1-7ac0-8eb1-0a5dc487b9c3") +EMPLOYMENT_ID = UUID("018d51d2-9ab1-7ac0-8eb1-0a5dc487b9c4") +POSITION_ID = UUID("018d51d2-9ab1-7ac0-8eb1-0a5dc487b9c5") +KNOWN_AT = datetime(2026, 8, 29, 0, 0, tzinfo=timezone.utc) + + +def assignment_row( + *, + tenant_record_id: object = TENANT_ID, + person_record_id: object = PERSON_ID, + allocation_ratio: object = Decimal("0.6000"), + recorded_from: object = datetime(2026, 8, 1, 0, 0), + recorded_to: object = None, +) -> tuple[object, ...]: + """Return one default DB row as projected by the governed SQL query.""" + return ( + tenant_record_id, + ASSIGNMENT_ID, + EMPLOYMENT_ID, + person_record_id, + POSITION_ID, + allocation_ratio, + date(2026, 8, 1), + None, + recorded_from, + recorded_to, + ) + + +class FakeCursor(AbstractContextManager["FakeCursor"]): + """Minimal DB-API cursor that records SQL and returns configured rows.""" + + def __init__(self, rows: object) -> None: + self.rows = rows + self.executions: list[tuple[str, object | None]] = [] + + def __enter__(self) -> "FakeCursor": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + return None + + def execute(self, statement: str, parameters: object | None = None) -> None: + self.executions.append((statement, parameters)) + + def fetchall(self) -> object: + return self.rows + + +class FakeConnection(AbstractContextManager["FakeConnection"]): + """Minimal connection exposing one stable cursor.""" + + def __init__(self, cursor: FakeCursor) -> None: + self._cursor = cursor + + def __enter__(self) -> "FakeConnection": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + return None + + def cursor(self) -> FakeCursor: + return self._cursor + + +class ConnectionFactory: + """Count connection acquisition so invalid request inputs can prove zero DB access.""" + + def __init__(self, rows: object) -> None: + self.calls = 0 + self.cursor = FakeCursor(rows) + + def __call__(self) -> FakeConnection: + self.calls += 1 + return FakeConnection(self.cursor) + + +class ZeroOffsetProvider(tzinfo): + """Caller-controlled timezone provider that must not cross the DB trust boundary.""" + + def utcoffset(self, dt: datetime | None) -> timedelta: + return timedelta(0) + + def dst(self, dt: datetime | None) -> timedelta: + return timedelta(0) + + +@pytest.mark.parametrize("invalid_factory", [None, 7, "connection"]) +def test_rejects_non_callable_connection_factory(invalid_factory: object) -> None: + with pytest.raises(TypeError, match="connection_factory must be callable"): + PostgresAssignmentHistoryReadPort(invalid_factory) # type: ignore[arg-type] + + +def test_read_is_tenant_scoped_read_only_half_open_and_returns_canonical_rows() -> None: + factory = ConnectionFactory( + [ + assignment_row( + recorded_to=datetime(2026, 9, 1, 0, 0), + ) + ] + ) + port = PostgresAssignmentHistoryReadPort(factory) + + records = port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + assert len(records) == 1 + record = records[0] + assert record.tenant_record_id == TENANT_ID + assert record.person_record_id == PERSON_ID + assert record.assignment_record_id == ASSIGNMENT_ID + assert record.recorded_from == datetime(2026, 8, 1, 0, 0, tzinfo=timezone.utc) + assert record.recorded_to == datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc) + assert record.allocation_ratio == Decimal("0.6000") + + assert factory.calls == 1 + assert len(factory.cursor.executions) == 3 + transaction_sql, transaction_parameters = factory.cursor.executions[0] + tenant_sql, tenant_parameters = factory.cursor.executions[1] + history_sql, history_parameters = factory.cursor.executions[2] + assert transaction_sql == "SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ ONLY" + assert transaction_parameters is None + assert "pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" in tenant_sql + assert tenant_parameters == (str(TENANT_ID),) + assert "FROM public.assignment_record AS assignment" in history_sql + assert "assignment.tenant_record_id = %s" in history_sql + assert "assignment.person_record_id = %s" in history_sql + assert "assignment.recorded_from <= %s" in history_sql + assert "%s < assignment.recorded_to" in history_sql + assert "AT TIME ZONE 'UTC'" in history_sql + assert "ORDER BY assignment.effective_from, assignment.assignment_record_id" in history_sql + assert "SELECT *" not in history_sql.upper() + assert history_parameters == (TENANT_ID, PERSON_ID, KNOWN_AT, KNOWN_AT) + + +def test_empty_database_result_returns_immutable_empty_tuple() -> None: + factory = ConnectionFactory([]) + port = PostgresAssignmentHistoryReadPort(factory) + + assert port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) == () + + +@pytest.mark.parametrize( + ("tenant_record_id", "person_record_id", "known_at", "message"), + [ + (UUID(int=0), PERSON_ID, KNOWN_AT, "tenant_record_id must be an operational UUID"), + (TENANT_ID, UUID(int=(1 << 128) - 1), KNOWN_AT, "person_record_id must be an operational UUID"), + (TENANT_ID, PERSON_ID, "2026-08-29", "known_at must be a timezone-aware UTC datetime"), + (TENANT_ID, PERSON_ID, datetime(2026, 8, 29), "known_at must be a timezone-aware UTC datetime"), + ( + TENANT_ID, + PERSON_ID, + datetime(2026, 8, 29, tzinfo=timezone(timedelta(hours=9))), + "known_at must be a timezone-aware UTC datetime", + ), + ( + TENANT_ID, + PERSON_ID, + datetime(2026, 8, 29, tzinfo=ZeroOffsetProvider()), + "known_at must be a timezone-aware UTC datetime", + ), + ], +) +def test_invalid_request_identity_or_time_fails_before_database_access( + tenant_record_id: object, + person_record_id: object, + known_at: object, + message: str, +) -> None: + factory = ConnectionFactory([]) + port = PostgresAssignmentHistoryReadPort(factory) + + with pytest.raises(ValueError, match=message): + port.read_assignment_history( # type: ignore[arg-type] + tenant_record_id=tenant_record_id, + person_record_id=person_record_id, + known_at=known_at, + ) + + assert factory.calls == 0 + + +def test_rejects_non_list_fetchall_result() -> None: + factory = ConnectionFactory((assignment_row(),)) + port = PostgresAssignmentHistoryReadPort(factory) + + with pytest.raises(AssignmentHistoryIntegrityError, match="immutable row list"): + port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize("row", [[1] * 10, (1, 2)]) +def test_rejects_unsupported_row_container_or_shape(row: object) -> None: + factory = ConnectionFactory([row]) + port = PostgresAssignmentHistoryReadPort(factory) + + with pytest.raises(AssignmentHistoryIntegrityError, match="row has an invalid shape"): + port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize( + ("recorded_from", "recorded_to"), + [ + ("2026-08-01", None), + (datetime(2026, 8, 1, tzinfo=timezone.utc), None), + (datetime(2026, 8, 1), "2026-09-01"), + (datetime(2026, 8, 1), datetime(2026, 9, 1, tzinfo=timezone.utc)), + ], +) +def test_rejects_noncanonical_database_timestamp_projection( + recorded_from: object, + recorded_to: object, +) -> None: + factory = ConnectionFactory([assignment_row(recorded_from=recorded_from, recorded_to=recorded_to)]) + port = PostgresAssignmentHistoryReadPort(factory) + + with pytest.raises(AssignmentHistoryIntegrityError, match="database recorded time must be a naive UTC projection"): + port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +def test_rejects_database_row_that_fails_assignment_record_integrity() -> None: + factory = ConnectionFactory([assignment_row(allocation_ratio=Decimal("NaN"))]) + port = PostgresAssignmentHistoryReadPort(factory) + + with pytest.raises(AssignmentHistoryIntegrityError, match="database assignment-history row failed integrity"): + port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize( + "row", + [ + assignment_row(tenant_record_id=UUID("018d51d2-9ab1-7ac0-8eb1-0a5dc487b9d1")), + assignment_row(person_record_id=UUID("018d51d2-9ab1-7ac0-8eb1-0a5dc487b9d2")), + ], +) +def test_rejects_row_outside_requested_tenant_or_person(row: tuple[object, ...]) -> None: + factory = ConnectionFactory([row]) + port = PostgresAssignmentHistoryReadPort(factory) + + with pytest.raises(AssignmentHistoryIntegrityError, match="does not match the requested target"): + port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize( + "row", + [ + assignment_row(recorded_from=datetime(2026, 8, 30, 0, 0)), + assignment_row(recorded_to=datetime(2026, 8, 29, 0, 0)), + ], +) +def test_rejects_row_outside_requested_system_knowledge_cutoff(row: tuple[object, ...]) -> None: + factory = ConnectionFactory([row]) + port = PostgresAssignmentHistoryReadPort(factory) + + with pytest.raises(AssignmentHistoryIntegrityError, match="not visible at the requested knowledge cutoff"): + port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +def test_open_recorded_interval_is_visible_at_known_at() -> None: + factory = ConnectionFactory([assignment_row()]) + port = PostgresAssignmentHistoryReadPort(factory) + + records = port.read_assignment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + assert records[0].recorded_to is None From 0acb916e34fcc711ecde6ea057e7f9ef0dd101a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:10:46 -0700 Subject: [PATCH 03/10] feat: implement postgres assignment history read adapter --- .../postgres_assignment_history.py | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 services/people-api/src/orgmetra_people_api/postgres_assignment_history.py diff --git a/services/people-api/src/orgmetra_people_api/postgres_assignment_history.py b/services/people-api/src/orgmetra_people_api/postgres_assignment_history.py new file mode 100644 index 000000000..a6f0f082d --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/postgres_assignment_history.py @@ -0,0 +1,168 @@ +"""PostgreSQL persistence adapter for purpose-bound Assignment-history reads. + +The parent service owns authorization. This adapter owns only a read-only, +tenant-scoped projection of canonical Orgmetra ``assignment_record`` truth and +returns typed rows for the parent service to revalidate before disclosure. +""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Callable +from uuid import UUID + +from orgmetra_people_api.assignment_history import ( + AssignmentHistoryIntegrityError, + AssignmentHistoryRecord, +) + +PostgresConnectionFactory = Callable[[], AbstractContextManager[Any]] + +_READ_ONLY_SQL = "SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ ONLY" +_TENANT_CONTEXT_SQL = "SELECT pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" +_ASSIGNMENT_HISTORY_SQL = """ +SELECT + assignment.tenant_record_id, + assignment.assignment_record_id, + assignment.employment_record_id, + assignment.person_record_id, + assignment.position_record_id, + assignment.allocation_ratio, + assignment.effective_from, + assignment.effective_to, + assignment.recorded_from AT TIME ZONE 'UTC' AS recorded_from_utc, + assignment.recorded_to AT TIME ZONE 'UTC' AS recorded_to_utc +FROM public.assignment_record AS assignment +WHERE assignment.tenant_record_id = %s + AND assignment.person_record_id = %s + AND assignment.recorded_from <= %s + AND (assignment.recorded_to IS NULL OR %s < assignment.recorded_to) +ORDER BY assignment.effective_from, assignment.assignment_record_id +""".strip() +_MAX_UUID_INT = (1 << 128) - 1 + + +def _require_operational_uuid(field_name: str, value: object) -> None: + """Require an exact non-sentinel UUID before any database access.""" + if type(value) is not UUID: + raise ValueError(f"{field_name} must be an operational UUID.") + if value.int in (0, _MAX_UUID_INT): + raise ValueError(f"{field_name} must be an operational UUID.") + + +def _require_utc_instant(field_name: str, value: object) -> None: + """Require exact built-in UTC time before using it as a history cutoff.""" + if type(value) is not datetime: + raise ValueError(f"{field_name} must be a timezone-aware UTC datetime.") + if type(value.tzinfo) is not timezone: + raise ValueError(f"{field_name} must be a timezone-aware UTC datetime.") + if value.utcoffset() != timedelta(0): + raise ValueError(f"{field_name} must be a timezone-aware UTC datetime.") + + +def _db_utc_instant(value: object) -> datetime: + """Attach built-in UTC only to PostgreSQL's explicit naive UTC projection.""" + if type(value) is not datetime: + raise AssignmentHistoryIntegrityError( + "database recorded time must be a naive UTC projection" + ) + if value.tzinfo is not None: + raise AssignmentHistoryIntegrityError( + "database recorded time must be a naive UTC projection" + ) + return value.replace(tzinfo=timezone.utc) + + +def _record_from_row(row: object) -> AssignmentHistoryRecord: + """Convert one untrusted DB-API row into the parent governed record type.""" + if type(row) is not tuple or len(row) != 10: + raise AssignmentHistoryIntegrityError("database assignment-history row has an invalid shape") + ( + tenant_record_id, + assignment_record_id, + employment_record_id, + person_record_id, + position_record_id, + allocation_ratio, + effective_from, + effective_to, + recorded_from, + recorded_to, + ) = row + try: + return AssignmentHistoryRecord( + tenant_record_id=tenant_record_id, + assignment_record_id=assignment_record_id, + employment_record_id=employment_record_id, + person_record_id=person_record_id, + position_record_id=position_record_id, + allocation_ratio=allocation_ratio, + effective_from=effective_from, + effective_to=effective_to, + recorded_from=_db_utc_instant(recorded_from), + recorded_to=None if recorded_to is None else _db_utc_instant(recorded_to), + ) + except ValueError as exc: + raise AssignmentHistoryIntegrityError( + "database assignment-history row failed integrity" + ) from exc + + +@dataclass(frozen=True, slots=True) +class PostgresAssignmentHistoryReadPort: + """Read canonical Assignment history through one tenant-scoped read-only transaction.""" + + connection_factory: PostgresConnectionFactory + + def __post_init__(self) -> None: + """Reject an unusable connection factory before a protected read can start.""" + if not callable(self.connection_factory): + raise TypeError("connection_factory must be callable") + + def read_assignment_history( + self, + *, + tenant_record_id: UUID, + person_record_id: UUID, + known_at: datetime, + ) -> tuple[AssignmentHistoryRecord, ...]: + """Return rows visible at ``known_at`` without authorizing disclosure itself.""" + _require_operational_uuid("tenant_record_id", tenant_record_id) + _require_operational_uuid("person_record_id", person_record_id) + _require_utc_instant("known_at", known_at) + + with self.connection_factory() as connection: + with connection.cursor() as cursor: + cursor.execute(_READ_ONLY_SQL) + cursor.execute(_TENANT_CONTEXT_SQL, (str(tenant_record_id),)) + cursor.execute( + _ASSIGNMENT_HISTORY_SQL, + (tenant_record_id, person_record_id, known_at, known_at), + ) + rows = cursor.fetchall() + + if type(rows) is not list: + raise AssignmentHistoryIntegrityError( + "database assignment-history read must return an immutable row list" + ) + + records: list[AssignmentHistoryRecord] = [] + for row in rows: + record = _record_from_row(row) + if ( + record.tenant_record_id != tenant_record_id + or record.person_record_id != person_record_id + ): + raise AssignmentHistoryIntegrityError( + "database assignment-history row does not match the requested target" + ) + if record.recorded_from > known_at or ( + record.recorded_to is not None and known_at >= record.recorded_to + ): + raise AssignmentHistoryIntegrityError( + "database assignment-history row is not visible at the requested knowledge cutoff" + ) + records.append(record) + return tuple(records) From ef7e5f24a4104427d10de7541de019cbca6ddd72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:11:08 -0700 Subject: [PATCH 04/10] feat: export postgres assignment history read adapter --- services/people-api/src/orgmetra_people_api/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index ca2ebf252..b096cb94d 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -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 @@ -81,6 +82,7 @@ "PeopleRecordNotFound", "PositionMutationCommand", "PositionMutationResult", + "PostgresAssignmentHistoryReadPort", "PostgresHireAcceptancePort", "PostgresPeopleMutationPort", "PostgresPeopleReadPort", From d3915da72b31847f701ba54fef0e851634f24939 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:11:33 -0700 Subject: [PATCH 05/10] docs: record postgres assignment history adapter ADR --- .../0148-postgres-assignment-history-read.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/adr/0148-postgres-assignment-history-read.md diff --git a/docs/adr/0148-postgres-assignment-history-read.md b/docs/adr/0148-postgres-assignment-history-read.md new file mode 100644 index 000000000..4921684eb --- /dev/null +++ b/docs/adr/0148-postgres-assignment-history-read.md @@ -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. From 221fc7ef316ffe500d4c8254993a8757366cd98d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:11:45 -0700 Subject: [PATCH 06/10] docs: record postgres read and RLS references --- ...gnment-history-postgres-read-references.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/doctoring/assignment-history-postgres-read-references.md diff --git a/docs/doctoring/assignment-history-postgres-read-references.md b/docs/doctoring/assignment-history-postgres-read-references.md new file mode 100644 index 000000000..2796653d9 --- /dev/null +++ b/docs/doctoring/assignment-history-postgres-read-references.md @@ -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. From 9984359df713a7032e9db65d9500c6de879b0c63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:12:06 -0700 Subject: [PATCH 07/10] docs: trace postgres assignment history red and ownership --- .../assignment-history-postgres-read.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/traceability/assignment-history-postgres-read.md diff --git a/docs/traceability/assignment-history-postgres-read.md b/docs/traceability/assignment-history-postgres-read.md new file mode 100644 index 000000000..0fb64d791 --- /dev/null +++ b/docs/traceability/assignment-history-postgres-read.md @@ -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. From 1344cbd3c12b453ea11f484c36d824317b495779 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:12:47 -0700 Subject: [PATCH 08/10] fix: clarify assignment history db row collection contract --- .../src/orgmetra_people_api/postgres_assignment_history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_assignment_history.py b/services/people-api/src/orgmetra_people_api/postgres_assignment_history.py index a6f0f082d..11b4dc5f8 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_assignment_history.py +++ b/services/people-api/src/orgmetra_people_api/postgres_assignment_history.py @@ -145,7 +145,7 @@ def read_assignment_history( if type(rows) is not list: raise AssignmentHistoryIntegrityError( - "database assignment-history read must return an immutable row list" + "database assignment-history read must return the default list row collection" ) records: list[AssignmentHistoryRecord] = [] From 12426438131acbeee01ab0729a621ceed4d576e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:13:27 -0700 Subject: [PATCH 09/10] test: cover adapter input type and clarify fetchall contract --- .../people-api/tests/test_postgres_assignment_history.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_history.py b/services/people-api/tests/test_postgres_assignment_history.py index 6e2cb3fee..37a97583c 100644 --- a/services/people-api/tests/test_postgres_assignment_history.py +++ b/services/people-api/tests/test_postgres_assignment_history.py @@ -5,7 +5,6 @@ from contextlib import AbstractContextManager from datetime import date, datetime, timedelta, timezone, tzinfo from decimal import Decimal -from typing import Any from uuid import UUID import pytest @@ -168,6 +167,7 @@ def test_empty_database_result_returns_immutable_empty_tuple() -> None: @pytest.mark.parametrize( ("tenant_record_id", "person_record_id", "known_at", "message"), [ + ("not-a-uuid", PERSON_ID, KNOWN_AT, "tenant_record_id must be an operational UUID"), (UUID(int=0), PERSON_ID, KNOWN_AT, "tenant_record_id must be an operational UUID"), (TENANT_ID, UUID(int=(1 << 128) - 1), KNOWN_AT, "person_record_id must be an operational UUID"), (TENANT_ID, PERSON_ID, "2026-08-29", "known_at must be a timezone-aware UTC datetime"), @@ -205,11 +205,11 @@ def test_invalid_request_identity_or_time_fails_before_database_access( assert factory.calls == 0 -def test_rejects_non_list_fetchall_result() -> None: +def test_rejects_non_default_fetchall_collection() -> None: factory = ConnectionFactory((assignment_row(),)) port = PostgresAssignmentHistoryReadPort(factory) - with pytest.raises(AssignmentHistoryIntegrityError, match="immutable row list"): + with pytest.raises(AssignmentHistoryIntegrityError, match="default list row collection"): port.read_assignment_history( tenant_record_id=TENANT_ID, person_record_id=PERSON_ID, From 927f108505603b49112f467ddb06b5c21843ee2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:14:33 -0700 Subject: [PATCH 10/10] test: run full People API coverage on assignment history adapter --- ...signment-history-postgres-read-quality.yml | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/.github/workflows/assignment-history-postgres-read-quality.yml b/.github/workflows/assignment-history-postgres-read-quality.yml index e2ebdde76..2e75d18da 100644 --- a/.github/workflows/assignment-history-postgres-read-quality.yml +++ b/.github/workflows/assignment-history-postgres-read-quality.yml @@ -6,10 +6,9 @@ on: - develop - feat/employee-profile-assignment-history-read paths: - - "services/people-api/src/orgmetra_people_api/assignment_history.py" - - "services/people-api/src/orgmetra_people_api/postgres_assignment_history.py" - - "services/people-api/src/orgmetra_people_api/__init__.py" - - "services/people-api/tests/test_postgres_assignment_history.py" + - "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" @@ -48,19 +47,13 @@ jobs: run: | python -m pip install --require-hashes --no-deps --only-binary=:all: -r .github/requirements/foundation-test.txt python -m pip check - - name: Compile assignment-history PostgreSQL boundary - run: python -m compileall -q services/people-api/src services/people-api/tests/test_postgres_assignment_history.py - - name: Test PostgreSQL assignment-history read with exact statement and branch coverage + - 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 -q - services/people-api/tests/test_postgres_assignment_history.py - --cov=orgmetra_people_api.postgres_assignment_history - --cov-branch - --cov-report=term-missing - --cov-fail-under=100 + run: python -m pytest -c services/people-api/pyproject.toml services/people-api/tests - name: Require clean checkout run: | git diff --exit-code