From 86cc40b123b575139379aebcd2becc86ed114c2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:22:19 +0900 Subject: [PATCH 1/2] test(people): define position history HTTP contract --- .../tests/test_position_history_http.py | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 services/people-api/tests/test_position_history_http.py diff --git a/services/people-api/tests/test_position_history_http.py b/services/people-api/tests/test_position_history_http.py new file mode 100644 index 00000000..189cbb5b --- /dev/null +++ b/services/people-api/tests/test_position_history_http.py @@ -0,0 +1,353 @@ +"""Executable HTTP transport contracts for governed Position-history reads.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import json +import re +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api import ( + AuthenticatedPrincipal, + AuthenticationFailed, + PositionHistoryIntegrityError, + PositionHistoryRecord, +) +from orgmetra_people_api.position_history_http import PositionHistoryAsgiApp + +TENANT = UUID("0198a413-6000-7000-8000-000000000001") +POSITION = UUID("0198a413-6000-7000-8000-000000000010") +VERSION_A = UUID("0198a413-6000-7000-8000-000000000020") +VERSION_B = UUID("0198a413-6000-7000-8000-000000000021") +ORGANIZATION = UUID("0198a413-6000-7000-8000-000000000030") +JOB_PROFILE = UUID("0198a413-6000-7000-8000-000000000040") +KNOWN_AT = datetime(2026, 8, 30, tzinfo=timezone.utc) +DEFAULT_QUERY = ( + b"known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&" + b"fields=effective_from,position_status_code" +) +_SUPPORT_REFERENCE = re.compile(r"^err_[A-Za-z0-9_-]{20,80}$") + + +class FakeAuthenticator: + """Return one principal while recording the opaque bearer token.""" + + def __init__(self, principal: AuthenticatedPrincipal, *, error: Exception | None = None) -> None: + self.principal = principal + self.error = error + self.tokens: list[str] = [] + + async def authenticate(self, bearer_token: str) -> AuthenticatedPrincipal: + """Authenticate one token without logging or returning its value.""" + self.tokens.append(bearer_token) + if self.error is not None: + raise self.error + return self.principal + + +class FakeReadPort: + """Return configured Position history and capture protected reads.""" + + def __init__(self, records: tuple[PositionHistoryRecord, ...]) -> None: + self.records = records + self.calls: list[tuple[UUID, UUID, datetime]] = [] + + def read_position_history( + self, + *, + tenant_record_id: UUID, + position_record_id: UUID, + known_at: datetime, + ) -> tuple[PositionHistoryRecord, ...]: + """Return deterministic history for transport tests.""" + self.calls.append((tenant_record_id, position_record_id, known_at)) + return self.records + + +class ExplodingReadPort: + """Model an unexpected persistence failure without leaking its details.""" + + def read_position_history( + self, + *, + tenant_record_id: UUID, + position_record_id: UUID, + known_at: datetime, + ) -> tuple[PositionHistoryRecord, ...]: + """Raise a secret-bearing error that must never reach the response body.""" + del tenant_record_id, position_record_id, known_at + raise RuntimeError("postgres password=do-not-leak") + + +def history_record( + *, + version_id: UUID = VERSION_A, + effective_from: date = date(2026, 1, 1), + effective_to: date | None = date(2026, 7, 1), +) -> PositionHistoryRecord: + """Build one canonical Position-history fixture.""" + return PositionHistoryRecord( + tenant_record_id=TENANT, + position_record_id=POSITION, + position_record_version_id=version_id, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB_PROFILE, + position_status_code="active", + effective_from=effective_from, + effective_to=effective_to, + recorded_from=datetime(2026, 1, 1, tzinfo=timezone.utc), + recorded_to=None, + ) + + +class PositionHistoryHttpRouteTests(unittest.IsolatedAsyncioTestCase): + """Prove the route preserves authentication, authorization, and lineage controls.""" + + def setUp(self) -> None: + self.principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({"orgmetra.people.position_history.read"}), + ) + self.policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="position-history-http-v1", + resource_kind="position_history", + purpose_code="workforce_position_review", + operation_code="read_record", + required_scope_code="orgmetra.people.position_history.read", + permitted_fields=frozenset( + {"effective_from", "position_status_code", "recorded_to"} + ), + ) + + def _app( + self, + *, + authenticator: object | None = None, + policy: object | None = None, + read_port: object | None = None, + ) -> PositionHistoryAsgiApp: + """Build the ASGI app with explicit injected boundaries.""" + return PositionHistoryAsgiApp( + authenticator=authenticator if authenticator is not None else FakeAuthenticator(self.principal), + policy=policy if policy is not None else self.policy, + read_port=read_port if read_port is not None else FakeReadPort((history_record(),)), + ) + + async def _request( + self, + app: PositionHistoryAsgiApp, + *, + method: str = "GET", + path: object | None = None, + query: object = DEFAULT_QUERY, + headers: object | None = None, + ) -> tuple[int, dict[bytes, bytes], dict[str, object]]: + scope = { + "type": "http", + "method": method, + "path": path + if path is not None + else f"/v1/tenants/{TENANT}/positions/{POSITION}/history", + "query_string": query, + "headers": headers if headers is not None else [(b"authorization", b"Bearer opaque-token")], + } + messages: list[dict[str, object]] = [] + + async def receive() -> dict[str, object]: + """Supply an empty ASGI request body.""" + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + """Capture the ASGI response messages.""" + messages.append(message) + + await app(scope, receive, send) + start, body = messages + response_headers = dict(start["headers"]) + return int(start["status"]), response_headers, json.loads(bytes(body["body"])) + + def test_constructor_rejects_missing_transport_dependencies(self) -> None: + """Keep authentication, policy, and persistence dependencies explicit.""" + with self.assertRaisesRegex(TypeError, "authenticator"): + self._app(authenticator=object()) + with self.assertRaisesRegex(TypeError, "policy"): + self._app(policy=object()) + with self.assertRaisesRegex(TypeError, "read_port"): + self._app(read_port=object()) + + async def test_get_history_returns_only_authorized_fields_with_private_cache_controls(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + + status, headers, payload = await self._request(app) + + self.assertEqual(status, 200) + self.assertEqual(headers[b"content-type"], b"application/json") + self.assertEqual(headers[b"cache-control"], b"no-store") + self.assertEqual(headers[b"vary"], b"Authorization") + self.assertEqual(payload["resource_reference"], f"position_history:{POSITION.hex}") + self.assertEqual( + payload["entries"], + [{"fields": {"effective_from": "2026-01-01", "position_status_code": "active"}}], + ) + self.assertEqual(authenticator.tokens, ["opaque-token"]) + self.assertEqual(port.calls, [(TENANT, POSITION, KNOWN_AT)]) + + async def test_empty_history_is_a_successful_empty_collection(self) -> None: + status, _, payload = await self._request(self._app(read_port=FakeReadPort(()))) + + self.assertEqual((status, payload["entries"]), (200, [])) + + async def test_malformed_request_fails_before_authentication_or_protected_read(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + zero_tenant_path = f"/v1/tenants/{UUID(int=0)}/positions/{POSITION}/history" + max_position_path = f"/v1/tenants/{TENANT}/positions/{UUID(int=(1 << 128) - 1)}/history" + cases = ( + {"path": "/v1/tenants/not-a-uuid/positions/not-a-uuid/history"}, + {"path": zero_tenant_path}, + {"path": max_position_path}, + {"query": "known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&fields=effective_from"}, + {"query": b"\xff"}, + {"query": b"bogus"}, + {"query": b"purpose=workforce_position_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&purpose=other&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00+00:00&purpose=workforce_position_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30&purpose=workforce_position_review&fields=effective_from"}, + {"query": b"known_at=not-a-time&purpose=workforce_position_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=WorkforceReview&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&fields="}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&fields=EffectiveFrom"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&fields=effective_from,effective_from"}, + ) + for case in cases: + with self.subTest(case=case): + status, _, payload = await self._request(app, **case) + self.assertEqual(status, 400) + self.assertEqual(payload["error_code"], "invalid_request") + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_wrong_path_and_method_return_transport_errors_without_authentication(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + + wrong_paths: tuple[object, ...] = ( + "/v1/unknown", + f"/v2/tenants/{TENANT}/positions/{POSITION}/history", + f"/v1/tenants/{TENANT}/position-records/{POSITION}/history", + 42, + ) + for path in wrong_paths: + with self.subTest(path=path): + status, _, payload = await self._request(app, path=path) + self.assertEqual((status, payload["error_code"]), (404, "route_not_found")) + status, headers, payload = await self._request(app, method="POST") + self.assertEqual((status, payload["error_code"]), (405, "method_not_allowed")) + self.assertEqual(headers[b"allow"], b"GET") + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_malformed_authorization_headers_are_unauthorized_without_authenticator_call(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + header_cases: tuple[object, ...] = ( + [], + object(), + [(b"authorization", b"Bearer one"), (b"authorization", b"Bearer two")], + [(b"x-request-id", b"request-1")], + [(b"authorization",)], + [("authorization", "Bearer opaque-token")], + [(b"authorization", b"Bearer \xff")], + ) + for headers in header_cases: + with self.subTest(headers=headers): + status, response_headers, payload = await self._request(app, headers=headers) + self.assertEqual((status, payload["error_code"]), (401, "authentication_required")) + self.assertEqual(response_headers[b"www-authenticate"], b"Bearer") + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_authenticator_rejection_is_unauthorized_without_protected_read(self) -> None: + authenticator = FakeAuthenticator(self.principal, error=AuthenticationFailed("expired")) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + + status, _, payload = await self._request(app) + + self.assertEqual((status, payload["error_code"]), (401, "authentication_required")) + self.assertEqual(authenticator.tokens, ["opaque-token"]) + self.assertEqual(port.calls, []) + + async def test_authorization_denial_does_not_read_position_history(self) -> None: + port = FakeReadPort((history_record(),)) + app = self._app(read_port=port) + + status, _, payload = await self._request( + app, + query=b"known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&fields=job_profile_id", + ) + + self.assertEqual((status, payload["error_code"]), (403, "access_denied")) + self.assertEqual(port.calls, []) + + async def test_integrity_conflict_returns_client_safe_error(self) -> None: + bad_record = PositionHistoryRecord( + tenant_record_id=TENANT, + position_record_id=UUID("0198a413-6000-7000-8000-000000000011"), + position_record_version_id=VERSION_B, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB_PROFILE, + position_status_code="active", + effective_from=date(2026, 1, 1), + effective_to=None, + recorded_from=datetime(2026, 1, 1, tzinfo=timezone.utc), + recorded_to=None, + ) + status, _, payload = await self._request( + self._app(read_port=FakeReadPort((bad_record,))), + ) + + self.assertEqual((status, payload["error_code"]), (409, "position_history_integrity_conflict")) + self.assertNotIn("position_record_id", json.dumps(payload)) + + async def test_unexpected_failure_returns_generic_500_without_secret_details(self) -> None: + status, _, payload = await self._request(self._app(read_port=ExplodingReadPort())) + + self.assertEqual((status, payload["error_code"]), (500, "internal_error")) + self.assertNotIn("password", json.dumps(payload)) + + async def test_errors_use_the_published_client_safe_envelope(self) -> None: + status, _, payload = await self._request(self._app(), path="/v1/not-the-position-history-route") + + self.assertEqual(status, 404) + self.assertEqual(payload["error"], payload["error_code"]) + self.assertEqual(payload["next_action"], payload["message"]) + self.assertRegex(payload["support_reference"], _SUPPORT_REFERENCE) + + async def test_non_http_scope_is_rejected_as_programming_error(self) -> None: + app = self._app(read_port=FakeReadPort(())) + + async def receive() -> dict[str, object]: + """Supply a lifespan message to prove it is never treated as HTTP.""" + return {"type": "lifespan.startup"} + + async def send(message: dict[str, object]) -> None: + """Reject any response for a non-HTTP scope.""" + del message + + with self.assertRaisesRegex(ValueError, "HTTP ASGI scopes"): + await app({"type": "lifespan"}, receive, send) + + +if __name__ == "__main__": + unittest.main() From eba070206b881cc0f7193fe684cf4cb6f8c2d54b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:33:59 +0900 Subject: [PATCH 2/2] feat(people): expose position history HTTP read --- .../position-history-http-quality.yml | 66 ++++ CHANGELOG.md | 1 + docs/API_CONTRACT.md | 6 +- docs/SECURITY.md | 2 + docs/TEST_STRATEGY.md | 1 + docs/TRACEABILITY.md | 1 + docs/UML.md | 27 +- docs/adr/0154-position-history-http-read.md | 62 ++++ docs/adr/README.md | 3 + .../position-history-http-read-references.md | 32 ++ .../position-history-http-read.md | 46 +++ manifest.json | 2 +- schemas/openapi.yaml | 114 +++++++ scripts/foundation-contract-core.mjs | 25 ++ services/people-api/README.md | 2 + .../src/orgmetra_people_api/__init__.py | 2 + .../position_history_http.py | 286 ++++++++++++++++++ .../tests/test_position_history_http.py | 2 +- tests/openapi-contract.test.mjs | 15 + tests/validate_repository.py | 25 ++ 20 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/position-history-http-quality.yml create mode 100644 docs/adr/0154-position-history-http-read.md create mode 100644 docs/doctoring/position-history-http-read-references.md create mode 100644 docs/traceability/position-history-http-read.md create mode 100644 services/people-api/src/orgmetra_people_api/position_history_http.py diff --git a/.github/workflows/position-history-http-quality.yml b/.github/workflows/position-history-http-quality.yml new file mode 100644 index 00000000..eeb28d14 --- /dev/null +++ b/.github/workflows/position-history-http-quality.yml @@ -0,0 +1,66 @@ +name: Position History HTTP Quality + +on: + pull_request: + branches: + - develop + - feat/people-position-history-postgres-adapter + paths: + - "services/people-api/**" + - "packages/hris-kernel/**" + - "packages/keyverse-adapter/**" + - "schemas/openapi.yaml" + - ".github/requirements/foundation-test.txt" + - ".github/workflows/position-history-http-quality.yml" + - "docs/API_CONTRACT.md" + - "docs/SECURITY.md" + - "docs/TEST_STRATEGY.md" + - "docs/TRACEABILITY.md" + - "docs/adr/0154-position-history-http-read.md" + - "docs/doctoring/position-history-http-read-references.md" + - "docs/traceability/position-history-http-read.md" + - "services/people-api/README.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: position-history-http-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Position-history HTTP 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-position-history-http.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)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f4752d..d712c721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Orgmetra will be documented in this file. ### Added +- Active stacked Position-history HTTP read boundary: `GET /v1/tenants/{tenant_record_id}/positions/{position_record_id}/history` validates an exact UTC `known_at` cutoff and requested fields before bearer authentication, delegates purpose-bound authorization and canonical PostgreSQL history retrieval to the existing People contracts, returns only authorized Position fields, and fails closed with the published client-safe error envelope. The dedicated quality workflow enforces exact current-head checkout and 100% People API statement/branch coverage. - Accepted ADRs 0001–0003 now include buyer-facing Context, Decision, and Consequences grounded in verified ISO 30400:2022, ISO 30414:2025, Uniform Guidelines (29 C.F.R. Part 1607), SIOP (2018), OpenAPI Specification v3.2.0, OpenID Connect Core 1.0 errata set 2, CloudEvents v1.0.2, Jensen and Snodgrass (1999), Snodgrass (1999), and Allen (1983) records already listed in `docs/doctoring/REFERENCES.md`. ADRs 0004 and 0005 gained APA 7th References pointers to that same bibliography without changing their Decision bodies. - Active-PR governed Job Analysis persistence/API on the canonical `JobAnalysisSnapshot` model: migration `0013_job_analysis_snapshot.sql` stores immutable tenant-scoped snapshot, Task, KSAO, Task–KSAO, FJA and write-command evidence; `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots` and matching GET enforce purpose-bound Keyverse scope, authenticated-principal actor authority, bounded/strict JSON handling, transactional Idempotency-Key serialization, parent-scope fail-closed integrity, forced RLS, and atomic audit/outbox evidence. ADR 0014 records the persistence decision while ADR 0007 remains the domain/evidence authority; validated evidence still requires accountable human review and non-LLM provenance, and the service does not make a high-impact employment decision. - Active-PR `orgmetra_selection_review` packet for PII-minimized, evidence-bound human selection review: canonical operational tenant identity, UUID-backed opaque candidate/Job/sealed-evidence/reviewer references, explicit purpose/reason/evidence version, deterministic canonical JSON and SHA-256 correlation, mandatory human decision state, redacted packet repr, and provenance-paired model evidence that remains `untrusted_draft`, with exact 100% owned statement and branch coverage required by its quality gate. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 27235d9c..e1a34b5b 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -14,6 +14,7 @@ The baseline scope contract is: |---|---| | People mutations | `orgmetra.people.write` | | Confirmed-hire materialization | `orgmetra.people.materialize_worker` | +| Position-history reads | `orgmetra.people.position_history.read` | | Job-architecture mutations | `orgmetra.job_architecture.write` | | Talent-acquisition mutations | `orgmetra.talent_acquisition.write` | @@ -49,6 +50,7 @@ The server rejects a reused idempotency key when its method, resource, tenant, a POST /v1/person-records GET /v1/person-records/{person_record_id} POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire +GET /v1/tenants/{tenant_record_id}/positions/{position_record_id}/history?known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&fields=effective_from,position_status_code POST /v1/employment-records POST /v1/position-records POST /v1/assignment-records @@ -62,6 +64,8 @@ POST /v1/validity-studies The foundation OpenAPI contract covers the shared command vocabulary and baseline person, employment, position, assignment, job-profile, and selection-decision operations. Runtime services must publish any additional path-specific contract before release and may not weaken the shared `Idempotency-Key`, least-privilege scope, authorization, evidence, or error semantics. Employment and assignment writes fail closed when exclusive jobs overlap, a seat is not staffable, or visible seat allocations exceed 1.0000. +Position-history reads are read-only and bitemporal. The route requires an RFC 3339 UTC `known_at` system-recorded cutoff with a trailing `Z`, an explicit business `purpose`, and a comma-separated `fields` set. The service authorizes the exact `position_history:{position_record_id}` target and returns only the authorized fields for Position versions visible at that cutoff; an empty visible history is a successful empty `entries` collection. The route performs no Person, Employment, Assignment, compensation, candidate, performance, credential, or employment-decision expansion. + ## Error shape ```json @@ -73,4 +77,4 @@ The foundation OpenAPI contract covers the shared command vocabulary and baselin } ``` -`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. \ No newline at end of file +`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fd6dd3ea..74c89b72 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -49,6 +49,8 @@ All mutation families additionally require resource-scoped authorization and a v A caller-controlled purpose value cannot substitute for a missing token scope. The OpenAPI contract is executable input to generated gateway and server validation; an implementation that accepts a request outside its published contract fails CI. +The Position-history HTTP boundary binds `known_at` to one explicit UTC system-time cutoff and authorizes the exact opaque Position-history target before invoking persistence. It returns only the requested authorized fields and fails closed on malformed identifiers, duplicate query fields, cross-tenant policy context, contradictory bitemporal rows, and unexpected backend failures; it does not expand the read into Person, Employment, Assignment, or high-impact decision data. + Internal traces remain in restricted telemetry. Customer-facing failures return a bounded `error_code`, actionable `message`, `next_action`, and random `support_reference`; the support lookup is access-controlled and retention-bound. The same governance contract applies to selection decisions, compensation changes, terminations, promotions, job-profile publication, validation-study policy changes, data exports, and identity deprovisioning. Draft creation may use a narrower permission, but publication or authoritative state transition may not reuse draft-only authorization. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index c20813b7..d8bfcce2 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -32,6 +32,7 @@ The command runs Python repository-integrity validation, the dependency-free Nod | Performance criterion observation Job, cycle, staffing, current-recorded-time, and UTC date-boundary integrity | `bash tests/test_criterion_observation_scope_postgres.sh` against PostgreSQL 16 in Foundation CI | | Governed People mutation idempotency: tenant/route/key uniqueness, identical-command replay, changed-command rejection, rollback safety, append-only/TRUNCATE protection, forced RLS and concurrent exact-key serialization | `bash tests/test_people_mutation_idempotency_postgres.sh` against PostgreSQL 16 in Foundation CI | | Tenant/actor/purpose authorization matrix and negative high-impact commands | service-specific unit and integration test commands recorded in each service package | +| Position-history HTTP parsing, authentication order, purpose/field minimization, bitemporal cutoff binding, error privacy, and PostgreSQL-backed read integration | `PYTHONPATH=services/people-api/src:packages/hris-kernel/src:packages/keyverse-adapter/src python -m pytest -c services/people-api/pyproject.toml services/people-api/tests` with exact 100% statement and branch coverage | | AsyncAPI/CloudEvents envelope compatibility | provider and consumer contract test commands recorded beside the versioned event schema | | External adapter timeout, malformed response, tenant mismatch, and unavailable-state handling | fake-server tests in each adapter package | | Role-workspace keyboard, focus, exact-value, permission-denied, and confirmation states | Storybook interaction/a11y tests plus browser E2E for the owning workspace | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 22a4178f..61ec1bff 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,6 +15,7 @@ | Governed candidate-to-worker conversion | Talent Acquisition / People core | `candidate_worker_conversion_record` with candidate, person, employment, selection decision, audit event and outbox evidence | PostgreSQL exact hire/evidence/audit-envelope binding, correction provenance, tenant RLS, legacy-write rejection and bitemporal history contract | ADR-0001, ADR-0003, ADR-0006 | implemented_on_protected_main | | GET-only People API | People API / purpose-bound read boundary | `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}`, `read_worker_people_record()`, `PostgresPeopleReadPort` | People API HTTP and PostgreSQL read contracts with exact 100% owned statement/branch coverage; current conversion lineage; no mutation writes | ADR-0002, ADR-0008 | implemented_on_protected_main | | Governed People writes and confirmed-hire materialization | People API / purpose-bound mutation boundary | `POST /v1/employment-records`, `POST /v1/position-records`, `POST /v1/assignment-records`, `POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions`, `people_mutation_idempotency_record` | People command/HTTP/PostgreSQL contracts with exact owned statement/branch coverage plus PostgreSQL tenant-RLS, atomic audit/outbox/idempotency, identical-retry replay, changed-command rejection, rollback, and concurrent-key regression | ADR-0002, ADR-0006, ADR-0008 | implemented_on_protected_main | +| Purpose-bound bitemporal Position-history reads | People API / read-only history boundary | `GET /v1/tenants/{tenant_record_id}/positions/{position_record_id}/history`, `PositionHistoryAsgiApp`, `read_position_history()`, `PostgresPositionHistoryReadPort` | HTTP parser/authentication-order/error-envelope contract plus typed service and real PostgreSQL tenant/RLS, UTC, knowledge-cutoff, Position/Job/organization-lineage evidence; exact 100% People API statement/branch coverage | ADR-0008, ADR-0152, ADR-0153, ADR-0154 | implemented_on_active_pr | | Evidence-grounded Job analysis with governed Task/FJA/KSAO persistence | Job Analysis / Workforce Validation | `JobAnalysisSnapshot`, `TaskEvidence`, `KSAORequirement`, `FunctionalJobAnalysisProfile`, `TaskKSAOLink`, `EvidenceSource`, `job_analysis_snapshot`, `job_analysis_task_item`, `job_analysis_ksao_item`, `job_analysis_task_ksao_link`, `job_analysis_write_command`, `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots`, `GET /v1/tenants/{tenant_record_id}/job-analysis-snapshots/{analysis_record_id}` | domain tenant/Job isolation, source/version/digest provenance, task-KSAO completeness, deterministic canonicalization, accountable human-review and LLM-draft-only regressions; migration 0013 PostgreSQL parent-scope/RLS/append-only/idempotency/audit-outbox persistence; exact route/OpenAPI/error contracts and 100% owned service statement/branch coverage | ADR-0007, ADR-0014 | implemented_on_active_pr | | Job-, cycle-, and staffing-scoped performance criterion observations | Performance / Workforce Validation | `criterion_observation`, `criterion_blueprint`, `performance_cycle`, `assignment_record`, `employment_record_version`, `position_record`, `position_record_version` | PostgreSQL wrong-Job, pre-assignment, out-of-cycle, frozen-Position, terminated-employment, closed-recorded-time, and session-TimeZone/UTC-midnight rejection plus valid worker-Job/staffing acceptance | ADR-0009 | implemented_on_protected_main | | Governed immutable audit and transactional outbox persistence | Audit Provenance / Integration Hub | `AuditOutboxEvent.canonical_json()`, `audit_event_record`, `outbox_delivery_record`, SHA-256 envelope digest | canonical-byte/digest regression plus PostgreSQL digest, allowlist/PII, high-impact confirmation, append-only, atomicity, lease-transition, terminal-state, and reserved-UUID tests | ADR-0006 | implemented_on_active_pr | diff --git a/docs/UML.md b/docs/UML.md index efc8b23d..50e673d7 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -46,6 +46,31 @@ flowchart LR The cluster is physically shared in the initial modular deployment. Each bounded context has a separate schema and role; direct reads of another context's application tables are prohibited. +## Position-history read sequence + +```mermaid +sequenceDiagram + actor Customer + participant Gateway + participant PositionHistory + participant Policy + participant PostgreSQL + + Customer->>Gateway: GET Position history(tenant, Position, known_at, purpose, fields) + Gateway->>Gateway: Validate route/query and authenticate Bearer token + Gateway->>Policy: Authorize exact target, purpose, scope, and fields + Policy-->>Gateway: Authorized field decision + Gateway->>PositionHistory: Read authorized bitemporal history + PositionHistory->>PostgreSQL: Tenant-scoped read-only query at known_at + PostgreSQL-->>PositionHistory: Typed Position/Job/organization lineage + PositionHistory-->>Gateway: Minimized authorized entries + Gateway-->>Customer: no-store response with opaque resource reference +``` + +Authorization precedes protected persistence access. The route does not join +Person, Employment, Assignment, compensation, candidate, performance, +credential, or employment-decision data. + ## Selection decision sequence ```mermaid @@ -119,4 +144,4 @@ sequenceDiagram PeopleCore->>Audit: Persist assignment, audit/outbox, and idempotency binding PeopleCore-->>Gateway: assignment_record Location Gateway-->>HROps: Review the roster, then approve or correct -``` \ No newline at end of file +``` diff --git a/docs/adr/0154-position-history-http-read.md b/docs/adr/0154-position-history-http-read.md new file mode 100644 index 00000000..aa092a14 --- /dev/null +++ b/docs/adr/0154-position-history-http-read.md @@ -0,0 +1,62 @@ +# ADR 0154: Expose governed Position history through a read-only HTTP boundary + +- **Status:** Proposed on active stacked PR #154; not protected-main truth until integrated +- **Date:** 2026-08-30 +- **Owners:** Orgmetra People API / customer read boundary +- **Extends:** ADR 0008 (purpose-bound PII authorization), ADR 0152 (Position-history read), ADR 0153 (PostgreSQL Position-history read) + +## Context + +PR #152 defines the purpose-bound Position-history use case and PR #153 supplies +the canonical PostgreSQL read adapter, but neither exposes a customer-callable +transport route. Deployments need one stable boundary that preserves the same +tenant, purpose, field, bitemporal, and no-disclosure controls without adding +Person, Employment, Assignment, or employment-decision authority. + +## Decision + +Add `PositionHistoryAsgiApp` with this route: + +```text +GET /v1/tenants/{tenant_record_id}/positions/{position_record_id}/history + ?known_at=YYYY-MM-DDTHH:MM:SSZ + &purpose=workforce_position_review + &fields=effective_from,position_status_code +``` + +The boundary validates operational UUIDs, exact required query keys, ASCII +query syntax, a UTC RFC 3339 `known_at` ending in `Z`, lower snake-case purpose +and fields, and duplicate-field/parameter rejection before authentication. It +reuses the existing People ASGI JSON transport and authorization-header parser, +authenticates exactly one Bearer credential, then delegates to +`read_position_history()`. The operation declares +`orgmetra.people.position_history.read`, returns only authorized fields, uses +`Cache-Control: no-store` and `Vary: Authorization`, and maps malformed input, +authentication, authorization, integrity, and unexpected failures to the +published client-safe error envelope. + +OpenAPI publishes the route, query/path parameters, `PositionHistoryView`, and +400/401/403/409/500 responses. The dedicated workflow checks the exact PR head, +compiles the service, and runs the complete People suite at 100% statement and +branch coverage. + +## Consequences + +- Customers receive one stable, read-only Position-history boundary. +- Existing Position-history service and PostgreSQL ownership boundaries remain + the only owners of authorization, bitemporal validation, and persistence. +- Error support references are opaque and safe for customer correlation; the + route does not expose backend exception details. +- The route intentionally does not add pagination, writes, cross-service joins, + or high-impact employment decisions; those require separate contracts. + +## Verification + +The test-only child head `86cc40b1` fails during collection while the HTTP +adapter module is absent. The implementation must retain that test-first chain, +show exact-current-head hosted evidence, and remain a Draft stacked PR until +independent review and all protected central gates are authoritative. + +RFC 3339, OpenAPI 3.2.0, NIST SP 800-53 Rev. 5, and PostgreSQL RLS/read-only +transaction guidance inform the boundary. They are defense-in-depth references, +not certification or merge evidence. diff --git a/docs/adr/README.md b/docs/adr/README.md index 099a2113..3723551d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,3 +16,6 @@ | [0012](0012-governed-migration-handoff.md) | Governed migration handoff | Accepted on active implementation branch | | [0013](0013-governed-requisition-review-packet.md) | Governed requisition review packet | Accepted on active implementation branch | | [0014](0014-job-analysis-snapshot-persistence.md) | Persist governed job-analysis snapshots | Accepted on active implementation branch | +| [0152](0152-purpose-bound-position-history-read.md) | Purpose-bound Position-history read contract | Accepted on active implementation branch | +| [0153](0153-postgres-position-history-read.md) | Read Position history from canonical PostgreSQL truth | Proposed on active stacked implementation branch | +| [0154](0154-position-history-http-read.md) | Expose governed Position history through a read-only HTTP boundary | Proposed on active stacked implementation branch | diff --git a/docs/doctoring/position-history-http-read-references.md b/docs/doctoring/position-history-http-read-references.md new file mode 100644 index 00000000..88a0a053 --- /dev/null +++ b/docs/doctoring/position-history-http-read-references.md @@ -0,0 +1,32 @@ +# Position-history HTTP read references + +**Scope:** Standards basis for active PR #154. This file does not claim certification or protected-main integration. + +## 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 + +OpenAPI Initiative. (2025). *OpenAPI Specification v3.2.0*. https://spec.openapis.org/oas/v3.2.0 + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/18/ddl-rowsecurity.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/18/sql-set-transaction.html + +## Decision relevance + +RFC 3339 constrains the customer-visible `known_at` representation to an +unambiguous UTC instant. OpenAPI defines the published operation, parameter, +response, and error-envelope contract. PostgreSQL row security and read-only +transaction controls remain persistence defense in depth; the application still +binds the tenant, purpose, exact target, requested fields, and service policy +before serialization. NIST access-control and information-integrity guidance +informs least privilege and fail-closed error handling without implying a +compliance outcome. + +## Research classification + +These references constrain the HTTP adapter architecture for PR #154. They do +not authorize scope expansion into Person, Employment, Assignment, compensation, +candidate, performance, credential, or automated employment-decision data. diff --git a/docs/traceability/position-history-http-read.md b/docs/traceability/position-history-http-read.md new file mode 100644 index 00000000..0621cda1 --- /dev/null +++ b/docs/traceability/position-history-http-read.md @@ -0,0 +1,46 @@ +# Position-history HTTP read traceability + +**Lifecycle status:** Active stacked PR #154 only. This document does not claim protected-`develop` integration. + +## Buyer problem + +PR #152 defines an authorized Position-history read and PR #153 connects it to +canonical PostgreSQL truth. A customer still needs one stable HTTP boundary to +request that history without deployment-specific parsing, authentication, or +serialization code widening the data surface. + +## Requirement-to-evidence matrix + +| Requirement | Production boundary | Regression | +| --- | --- | --- | +| Validate before protected work | `PositionHistoryAsgiApp` parses route, query, UUIDs, UTC cutoff, purpose, and fields before authentication | malformed input cases prove no authenticator or read-port call | +| Authenticate one bearer credential | existing `_authorization_header` and `extract_bearer_token` contracts | missing, duplicate, malformed, non-ASCII, and rejected credentials return 401 | +| Use least privilege and exact purpose | `orgmetra.people.position_history.read` plus `read_position_history()` policy binding | disallowed fields return 403 before the port is called | +| Preserve bitemporal scope | `known_at` is an exact UTC system-recorded cutoff passed to the Position-history service | call capture and service/real PostgreSQL cutoff tests | +| Minimize the response | `resource_reference` plus authorized `entries[].fields` only | successful and empty-result response assertions; no Person/Employment/Assignment joins | +| Fail closed without disclosure | stable 400/401/403/409/500 client-safe envelopes and opaque support reference | integrity and secret-bearing backend failures assert no internal details | +| Publish the same customer contract | OpenAPI route, parameters, schema, scope, and responses | Python/Node structural OpenAPI mutation tests | +| Keep evidence on the exact candidate | dedicated workflow checks PR head and complete People suite | compile, exact 100% statement/branch coverage, and clean checkout | + +## Test-first chain + +1. **Contract-only child head:** `86cc40b1` adds HTTP regressions while `orgmetra_people_api.position_history_http` is absent. +2. **Expected RED:** focused collection fails with `ModuleNotFoundError` at that owning module boundary; this is distinct from the missing dependency-path invocation. +3. **Implementation:** add the smallest separate ASGI adapter, package-root export, OpenAPI contract, and dedicated quality workflow. +4. **Verification:** run the full People API suite with exact statement and branch coverage, repository validation, actionlint, CodeGraph synchronization, and current-head hosted checks. + +## Security and data boundary + +The route reads only authorized Position-version fields and the already-governed +Position/Job/organization lineage. It does not join Person, Employment, +Assignment, compensation, candidate, performance, credential, prompt, or model +output data. It performs no write, audit/outbox mutation, or high-impact +employment decision. + +## Out of scope + +- Pagination or export workflows. +- Position correction or mutation workflows. +- Cross-service application-database queries. +- Browser UI, Storybook, or Figma work; this slice is a transport contract. +- Release, tag, publication, or protected-default-branch authority. diff --git a/manifest.json b/manifest.json index 97f2bab1..2b52b7f4 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"32cc4ef78d1eca557fa01731026840be01211a043eb0ada552e4e6cb9eace353","bytes":17295,"lines":76},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac","bytes":11185,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8","bytes":16534,"lines":135},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e","bytes":11462,"lines":40},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002","bytes":1838,"lines":18},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"595e8381dbd62e97093b11eef818af5f04d6473ac592d57e3985ffbc2210d445","bytes":28173,"lines":689},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"918cf92fd18d81572e9bd5f5daa7f033c32731e2e13f0d00661d1c1de30b12a9","bytes":27291,"lines":638}]} +{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"cd672c2d03412ceac75a2377f00bb8526afaf3ac7a90c5c0aae7f6ec6d262ee2","bytes":17852,"lines":77},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"3384cfb7baba2102f5680443efbba3dea7ce706fde3645ed7691866457eb067a","bytes":5391,"lines":80},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"d88a9e3175ec953821b1b60f78d98426ad2f74843476bafa31a0e14467ec196c","bytes":11665,"lines":66},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"6500d58f9fb1bf83aabb16e608efc1d3698a4d1f27a01214b36877cc8a81213e","bytes":16921,"lines":136},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"5e6e82516ea393f83419dabe0e6b7f42c783d2af4d165ead82fbde140108b439","bytes":12015,"lines":41},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"debab73fc43f06d7592f32dadfefd8674c2c2996f10a5e9c531ad287539125d7","bytes":6549,"lines":147},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"51f7adb9a3687e8f935d1219acc228fb3ba7eae8c8abcced0b9cf613cb7176eb","bytes":2309,"lines":21},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"c303f7b92737ac7f3e94c4cf07c7f4609abda295dbe629cea2ef38d80d74dd00","bytes":33036,"lines":1134},{"path":"scripts/foundation-contract-core.mjs","sha256":"35e88c6b063a06be8ea7f3f93ea88600040f32bf0d9d304d3041386ef35e773b","bytes":29540,"lines":714},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"6247322ba1371a421656f414416a6f69e34235b669e3fba3b8e3907e4b62b1a8","bytes":6975,"lines":210},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"af2c15c22cb60a42870711af98c6a0f76c6c59c9f4e99b89c4bbd6e29053b52c","bytes":28690,"lines":663}]} diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 0fd397e9..01d7474f 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -8,6 +8,8 @@ servers: tags: - name: people-core description: People identity and employment commands. + - name: people-history + description: Purpose-bound bitemporal People history reads. - name: job-architecture description: Evidence-backed job architecture commands. - name: talent-acquisition @@ -344,6 +346,42 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/SnapshotNotFound' + /tenants/{tenant_record_id}/positions/{position_record_id}/history: + get: + operationId: readPositionHistory + summary: Read purpose-authorized bitemporal Position history at a knowledge cutoff + tags: + - people-history + security: + - keyverse_oidc: + - orgmetra.people.position_history.read + parameters: + - $ref: '#/components/parameters/TenantRecordId' + - $ref: '#/components/parameters/PositionRecordId' + - $ref: '#/components/parameters/KnownAt' + - $ref: '#/components/parameters/PurposeQuery' + - $ref: '#/components/parameters/FieldsQuery' + responses: + '200': + description: Authorized Position versions visible at the requested system-time cutoff. + content: + application/json: + schema: + $ref: '#/components/schemas/PositionHistoryView' + '400': + $ref: '#/components/responses/InvalidCommand' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: Authoritative Position history failed integrity validation. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + $ref: '#/components/responses/InternalError' components: securitySchemes: keyverse_oidc: @@ -383,6 +421,51 @@ components: schema: type: string pattern: '^[a-z][a-z0-9_]{2,63}$' + TenantRecordId: + name: tenant_record_id + in: path + required: true + schema: + type: string + format: uuid + PositionRecordId: + name: position_record_id + in: path + required: true + schema: + type: string + format: uuid + KnownAt: + name: known_at + in: query + required: true + description: RFC 3339 UTC system-recorded knowledge cutoff, including a trailing Z. + schema: + type: string + format: date-time + pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$' + PurposeQuery: + name: purpose + in: query + required: true + description: Business purpose evaluated by the purpose-bound authorization policy. + schema: + type: string + minLength: 1 + maxLength: 64 + pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + FieldsQuery: + name: fields + in: query + required: true + description: Comma-separated explicit lower snake-case fields to request. + style: form + explode: false + schema: + type: string + minLength: 1 + maxLength: 1000 + pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*(,[a-z][a-z0-9]*(?:_[a-z0-9]+)*)*$' schemas: EvidenceReference: type: object @@ -422,6 +505,31 @@ components: person_record_id: type: string format: uuid + PositionHistoryView: + type: object + additionalProperties: false + required: + - resource_reference + - entries + properties: + resource_reference: + type: string + minLength: 1 + maxLength: 300 + entries: + type: array + items: + $ref: '#/components/schemas/PositionHistoryEntry' + PositionHistoryEntry: + type: object + additionalProperties: false + required: + - fields + properties: + fields: + type: object + additionalProperties: + type: [string, 'null'] CreateJobProfileCommand: type: object additionalProperties: false @@ -1018,3 +1126,9 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + InternalError: + description: The service could not safely complete the request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 1e9fb267..f7a547fb 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -539,6 +539,31 @@ export function validateOpenApiContract(openapiText) { requireWithin(errors, 'readJobAnalysisSnapshot', jobAnalysisRead, ' - orgmetra.job_architecture.read', 'least-privilege read scope'); } + const positionHistoryRead = extractYamlBlock( + openapiText, + ' /tenants/{tenant_record_id}/positions/{position_record_id}/history:' + ); + if (!positionHistoryRead) { + errors.push('readPositionHistory: path block is missing'); + } else { + requireWithin(errors, 'readPositionHistory', positionHistoryRead, 'operationId: readPositionHistory', 'operationId'); + requireWithin(errors, 'readPositionHistory', positionHistoryRead, ' - orgmetra.people.position_history.read', 'least-privilege read scope'); + for (const parameterName of ['TenantRecordId', 'PositionRecordId', 'KnownAt', 'PurposeQuery', 'FieldsQuery']) { + requireWithin( + errors, + 'readPositionHistory', + positionHistoryRead, + `$ref: '#/components/parameters/${parameterName}'`, + `required parameter ${parameterName}` + ); + } + requireWithin(errors, 'readPositionHistory', positionHistoryRead, " '200':", 'response 200'); + requireWithin(errors, 'readPositionHistory', positionHistoryRead, "$ref: '#/components/schemas/PositionHistoryView'", 'response schema'); + for (const responseCode of [" '400':", " '401':", " '403':", " '409':", " '500':"]) { + requireWithin(errors, 'readPositionHistory', positionHistoryRead, responseCode, `response ${responseCode.trim()}`); + } + } + const jobCommand = extractYamlBlock(openapiText, ' CreateJobProfileCommand:'); if (!jobCommand) { errors.push('CreateJobProfileCommand: schema block is missing'); diff --git a/services/people-api/README.md b/services/people-api/README.md index 548a8344..141db7ca 100644 --- a/services/people-api/README.md +++ b/services/people-api/README.md @@ -10,6 +10,8 @@ The service exposes a governed hire-to-employment read contract. `read_worker_pe `PeopleAsgiApp` exposes that governed read use case as a dependency-light ASGI route: `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}?effective_on=YYYY-MM-DD&purpose=people_read&fields=...`. It validates the exact route and query shape before authentication, accepts exactly one ASCII Bearer credential, delegates authentication and purpose-bound authorization to injected contracts, and never reads protected worker values after a denied authorization decision. Successful responses contain only authorized fields; all HTTP responses use `Cache-Control: no-store` and `Vary: Authorization`. Authentication, authorization, missing-record, integrity-conflict, and unexpected-backend failures are mapped to stable non-disclosing responses with a useful next action, and bearer tokens are never returned in response text. +`PositionHistoryAsgiApp` exposes the bitemporal Position-history read use case as a separate dependency-light ASGI route: `GET /v1/tenants/{tenant_record_id}/positions/{position_record_id}/history?known_at=...&purpose=...&fields=...`. It validates operational UUIDs, the exact UTC `known_at` representation, purpose, and requested fields before authentication; then delegates to `read_position_history()` and the injected `PositionHistoryReadPort`. The service authorizes before the port is called, revalidates returned Position/Job/organization lineage and system-time visibility, and serializes only explicitly authorized fields. The route is read-only, uses the existing no-store/authorization-vary response transport, and exposes no Person, Employment, Assignment, compensation, candidate, performance, credential, or employment-decision data. + The People API quality workflow is part of this contract and must run for pull requests to every supported protected/default integration branch, including `develop`. Its service tests enforce 100% owned statement and branch coverage and include regression coverage for the workflow dispatch boundary and HTTP security/transport behavior. `HireAcceptanceAsgiApp` exposes confirmed-hire materialization as `POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire`. Authentication and tenant binding occur before request-body parsing, so an unauthenticated or foreign-tenant caller cannot use body parsing or command construction as an oracle. Authenticated requests then pass the validated `Idempotency-Key`, content-type, JSON/schema, authorization, and governed command checks under a 64 KiB cumulative request-body limit, at most 1024 ASGI request frames, and 128 nested JSON containers below the top-level command object. `PostgresHireAcceptancePort` acquires a transaction-scoped lock for the tenant/route/key before it persists Person, Employment, `candidate_worker_conversion_record`, governed audit/outbox evidence, and `people_mutation_idempotency_record` in one tenant-bound transaction. An exact retry returns the first committed person/employment/conversion identities without repeating necessary PII, audit, or outbox writes; reusing the key for changed command semantics fails closed. The legacy `candidate_worker_link` write path is not used. diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index 20c9908e..ed39161a 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -18,6 +18,7 @@ from orgmetra_people_api.hire_http import HireAcceptanceAsgiApp from orgmetra_people_api.http import PeopleAsgiApp from orgmetra_people_api.mutation_http import PeopleMutationAsgiApp +from orgmetra_people_api.position_history_http import PositionHistoryAsgiApp from orgmetra_people_api.mutations import ( AssignmentMutationCommand, AssignmentMutationResult, @@ -67,6 +68,7 @@ "HireDecisionNotFound", "PeopleAsgiApp", "PeopleMutationAsgiApp", + "PositionHistoryAsgiApp", "PeopleMutationIntegrityError", "PeopleMutationNotFound", "PeopleMutationPort", diff --git a/services/people-api/src/orgmetra_people_api/position_history_http.py b/services/people-api/src/orgmetra_people_api/position_history_http.py new file mode 100644 index 00000000..0661c8e6 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/position_history_http.py @@ -0,0 +1,286 @@ +"""Dependency-light ASGI route for governed Position-history reads. + +The transport adapter owns request parsing and client-safe responses. The +Position-history service remains responsible for purpose-bound authorization, +bitemporal integrity, and minimizing the fields returned to the caller. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging +import re +from secrets import token_urlsafe +from typing import Mapping +from urllib.parse import parse_qsl +from uuid import UUID + +from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy + +from orgmetra_people_api.auth import ( + AuthenticationFailed, + TokenAuthenticator, + extract_bearer_token, +) +from orgmetra_people_api.http import ( + AsgiReceive, + AsgiSend, + _authorization_header, + _send_json as _emit_json, +) +from orgmetra_people_api.position_history import ( + PositionHistoryIntegrityError, + PositionHistoryReadPort, + read_position_history, +) + +_LOGGER = logging.getLogger(__name__) +_ROUTE_PREFIX = ("v1", "tenants") +_PURPOSE_PATTERN = re.compile(r"\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\Z", flags=re.ASCII) +_FIELD_PATTERN = re.compile(r"\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\Z", flags=re.ASCII) +_RFC3339_INSTANT_PATTERN = re.compile( + r"\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z\Z", + flags=re.ASCII, +) +_MAX_UUID_INT = (1 << 128) - 1 +_REQUIRED_QUERY_KEYS = frozenset({"known_at", "purpose", "fields"}) +_SUPPORT_REFERENCE_RANDOM_BYTES = 24 + + +class _InvalidHttpRequest(ValueError): + """Indicate malformed Position-history route input that must fail closed.""" + + +@dataclass(frozen=True, slots=True) +class _ParsedPositionHistoryRequest: + """Hold validated path and query values for one Position-history read.""" + + tenant_record_id: UUID + position_record_id: UUID + known_at: datetime + purpose_code: str + requested_fields: frozenset[str] + + +async def _send_error( + send: AsgiSend, + *, + status: int, + error_code: str, + message: str, + extra_headers: tuple[tuple[bytes, bytes], ...] = (), +) -> None: + """Emit a client-safe error envelope with an opaque support reference.""" + support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + _LOGGER.info( + "Position-history request rejected", + extra={ + "error_code": error_code, + "http_status": status, + "support_reference": support_reference, + }, + ) + await _emit_json( + send, + status=status, + payload={ + "error": error_code, + "error_code": error_code, + "message": message, + "next_action": message, + "support_reference": support_reference, + }, + extra_headers=extra_headers, + ) + + +@dataclass(frozen=True, slots=True) +class PositionHistoryAsgiApp: + """Expose one tenant-scoped, read-only Position-history route. + + Supported route:: + + GET /v1/tenants/{tenant_record_id}/positions/{position_record_id}/history + ?known_at=YYYY-MM-DDTHH:MM:SSZ + &purpose=workforce_position_review + &fields=effective_from,position_status_code + + The app contains no web-framework dependency and returns only the + purpose-authorized Position-history fields from the governed service. + """ + + authenticator: TokenAuthenticator + policy: PurposeBoundAccessPolicy + read_port: PositionHistoryReadPort + + def __post_init__(self) -> None: + """Reject incomplete dependencies before serving protected data.""" + if not isinstance(self.authenticator, TokenAuthenticator): + raise TypeError("authenticator must implement TokenAuthenticator") + if not isinstance(self.policy, PurposeBoundAccessPolicy): + raise TypeError("policy must be a PurposeBoundAccessPolicy") + if not isinstance(self.read_port, PositionHistoryReadPort): + raise TypeError("read_port must implement PositionHistoryReadPort") + + async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send: AsgiSend) -> None: + """Serve one HTTP request without exposing bearer tokens or internals.""" + del receive + if scope.get("type") != "http": + raise ValueError("PositionHistoryAsgiApp accepts only HTTP ASGI scopes") + + if scope.get("method") != "GET": + await _send_error( + send, + status=405, + error_code="method_not_allowed", + message="Use GET for the governed Position-history read route.", + extra_headers=((b"allow", b"GET"),), + ) + return + + path = scope.get("path") + if not isinstance(path, str) or not _looks_like_position_history_route(path): + await _send_error( + send, + status=404, + error_code="route_not_found", + message="Use /v1/tenants/{tenant_record_id}/positions/{position_record_id}/history.", + ) + return + + try: + request = _parse_position_history_request(path, scope.get("query_string", b"")) + except _InvalidHttpRequest: + await _send_error( + send, + status=400, + error_code="invalid_request", + message="Correct the tenant/Position IDs and required known_at, purpose, and fields query parameters, then retry.", + ) + return + + try: + bearer_token = extract_bearer_token(_authorization_header(scope)) + principal = await self.authenticator.authenticate(bearer_token) + except AuthenticationFailed: + await _send_error( + send, + status=401, + error_code="authentication_required", + message="Provide one valid Bearer credential and retry.", + extra_headers=((b"www-authenticate", b"Bearer"),), + ) + return + + try: + view = read_position_history( + principal=principal, + tenant_record_id=request.tenant_record_id, + position_record_id=request.position_record_id, + known_at=request.known_at, + purpose_code=request.purpose_code, + requested_fields=request.requested_fields, + policy=self.policy, + read_port=self.read_port, + ) + except AuthorizationDeniedError: + await _send_error( + send, + status=403, + error_code="access_denied", + message="Request only fields and a purpose authorized for this exact Position history.", + ) + return + except PositionHistoryIntegrityError: + await _send_error( + send, + status=409, + error_code="position_history_integrity_conflict", + message="The Position history cannot be returned safely; ask an Orgmetra operator to inspect the authoritative lineage.", + ) + return + except Exception: # noqa: BLE001 - HTTP boundary must fail closed without backend details. + await _send_error( + send, + status=500, + error_code="internal_error", + message="Retry later or contact an Orgmetra operator with non-secret request metadata; never include the bearer token.", + ) + return + + await _emit_json( + send, + status=200, + payload={ + "resource_reference": view.resource_reference, + "entries": [ + {"fields": dict(entry.field_values)} + for entry in view.entries + ], + }, + ) + + +def _looks_like_position_history_route(path: str) -> bool: + """Recognize the versioned Position-history route before parsing IDs.""" + parts = path.strip("/").split("/") + return ( + len(parts) == 6 + and tuple(parts[:2]) == _ROUTE_PREFIX + and parts[3] == "positions" + and parts[5] == "history" + ) + + +def _parse_position_history_request(path: str, raw_query: object) -> _ParsedPositionHistoryRequest: + """Validate all caller-controlled path/query values before authentication.""" + parts = path.strip("/").split("/") + try: + tenant_record_id = UUID(parts[2]) + position_record_id = UUID(parts[4]) + except (ValueError, IndexError) as error: + raise _InvalidHttpRequest("route IDs must be UUIDs") from error + if tenant_record_id.int in (0, _MAX_UUID_INT) or position_record_id.int in (0, _MAX_UUID_INT): + raise _InvalidHttpRequest("route IDs must be operational UUIDs") + + if not isinstance(raw_query, bytes): + raise _InvalidHttpRequest("query_string must be bytes") + try: + query_text = raw_query.decode("ascii") + pairs = parse_qsl(query_text, keep_blank_values=True, strict_parsing=True) + except (UnicodeDecodeError, ValueError) as error: + raise _InvalidHttpRequest("query string is malformed") from error + + query: dict[str, str] = {} + for key, value in pairs: + if key in query: + raise _InvalidHttpRequest("duplicate query parameter") + query[key] = value + if frozenset(query) != _REQUIRED_QUERY_KEYS: + raise _InvalidHttpRequest("query parameters are incomplete or unsupported") + + raw_known_at = query["known_at"] + if _RFC3339_INSTANT_PATTERN.fullmatch(raw_known_at) is None: + raise _InvalidHttpRequest("known_at must be a UTC RFC 3339 instant") + try: + known_at = datetime.fromisoformat(raw_known_at[:-1] + "+00:00") + except ValueError as error: + raise _InvalidHttpRequest("known_at must be a valid UTC RFC 3339 instant") from error + purpose_code = query["purpose"] + if _PURPOSE_PATTERN.fullmatch(purpose_code) is None: + raise _InvalidHttpRequest("purpose must be a lower snake-case code") + + raw_fields = query["fields"].split(",") + if any(_FIELD_PATTERN.fullmatch(field) is None for field in raw_fields): + raise _InvalidHttpRequest("fields must be explicit lower snake-case names") + if len(set(raw_fields)) != len(raw_fields): + raise _InvalidHttpRequest("fields must not repeat") + + return _ParsedPositionHistoryRequest( + tenant_record_id=tenant_record_id, + position_record_id=position_record_id, + known_at=known_at, + purpose_code=purpose_code, + requested_fields=frozenset(raw_fields), + ) diff --git a/services/people-api/tests/test_position_history_http.py b/services/people-api/tests/test_position_history_http.py index 189cbb5b..1347ed92 100644 --- a/services/people-api/tests/test_position_history_http.py +++ b/services/people-api/tests/test_position_history_http.py @@ -12,7 +12,6 @@ from orgmetra_people_api import ( AuthenticatedPrincipal, AuthenticationFailed, - PositionHistoryIntegrityError, PositionHistoryRecord, ) from orgmetra_people_api.position_history_http import PositionHistoryAsgiApp @@ -221,6 +220,7 @@ async def test_malformed_request_fails_before_authentication_or_protected_read(s {"query": b"known_at=2026-08-30T00:00:00+00:00&purpose=workforce_position_review&fields=effective_from"}, {"query": b"known_at=2026-08-30&purpose=workforce_position_review&fields=effective_from"}, {"query": b"known_at=not-a-time&purpose=workforce_position_review&fields=effective_from"}, + {"query": b"known_at=2026-02-30T00:00:00Z&purpose=workforce_position_review&fields=effective_from"}, {"query": b"known_at=2026-08-30T00:00:00Z&purpose=WorkforceReview&fields=effective_from"}, {"query": b"known_at=2026-08-30T00:00:00Z&purpose=&fields=effective_from"}, {"query": b"known_at=2026-08-30T00:00:00Z&purpose=workforce_position_review&fields="}, diff --git a/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 8e98078d..856d2d7a 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -117,6 +117,21 @@ for (const testCase of [ fragment: " '422':\n", expected: /recordSelectionDecision.*response.*422/ }, + { + name: 'Position-history path', + fragment: ' /tenants/{tenant_record_id}/positions/{position_record_id}/history:\n', + expected: /readPositionHistory.*path block/ + }, + { + name: 'Position-history read scope', + fragment: ' - orgmetra.people.position_history.read\n', + expected: /readPositionHistory.*scope/ + }, + { + name: 'Position-history response schema', + fragment: " $ref: '#/components/schemas/PositionHistoryView'\n", + expected: /readPositionHistory.*response schema/ + }, { name: 'safe support reference error field', fragment: ' - support_reference\n', diff --git a/tests/validate_repository.py b/tests/validate_repository.py index fe0a329f..db06b025 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -535,6 +535,31 @@ def _validate_openapi_contract() -> None: "least-privilege read scope", ) + position_history_read_block = _yaml_block( + openapi, + " /tenants/{tenant_record_id}/positions/{position_record_id}/history:", + ) + if not position_history_read_block: + _fail("readPositionHistory: path block is missing") + else: + for fragment, description in ( + ("operationId: readPositionHistory", "operationId"), + (" - orgmetra.people.position_history.read", "least-privilege read scope"), + ("$ref: '#/components/parameters/TenantRecordId'", "tenant path parameter"), + ("$ref: '#/components/parameters/PositionRecordId'", "Position path parameter"), + ("$ref: '#/components/parameters/KnownAt'", "knowledge-cutoff parameter"), + ("$ref: '#/components/parameters/PurposeQuery'", "purpose query parameter"), + ("$ref: '#/components/parameters/FieldsQuery'", "fields query parameter"), + (" '200':", "200 response"), + ("$ref: '#/components/schemas/PositionHistoryView'", "response schema"), + (" '400':", "400 response"), + (" '401':", "401 response"), + (" '403':", "403 response"), + (" '409':", "409 response"), + (" '500':", "500 response"), + ): + _require_in_block(position_history_read_block, "readPositionHistory", fragment, description) + for schema_name in ( "CreateJobProfileCommand", "RecordSelectionDecisionCommand",