From 6c2d6b8915a2594cd9d563cfd596fb96904c1086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:49:37 +0900 Subject: [PATCH 01/27] test(people): define employment history HTTP contract --- .../tests/test_employment_history_http.py | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 services/people-api/tests/test_employment_history_http.py diff --git a/services/people-api/tests/test_employment_history_http.py b/services/people-api/tests/test_employment_history_http.py new file mode 100644 index 000000000..e6504d65b --- /dev/null +++ b/services/people-api/tests/test_employment_history_http.py @@ -0,0 +1,354 @@ +"""Executable HTTP transport contracts for governed Employment-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, + EmploymentHistoryRecord, +) +from orgmetra_people_api.employment_history_http import EmploymentHistoryAsgiApp + +TENANT = UUID("0198a414-6000-7000-8000-000000000001") +PERSON = UUID("0198a414-6000-7000-8000-000000000010") +VERSION_A = UUID("0198a414-6000-7000-8000-000000000020") +VERSION_B = UUID("0198a414-6000-7000-8000-000000000021") +EMPLOYMENT_A = UUID("0198a414-6000-7000-8000-000000000030") +EMPLOYMENT_B = UUID("0198a414-6000-7000-8000-000000000031") +KNOWN_AT = datetime(2026, 8, 30, tzinfo=timezone.utc) +DEFAULT_QUERY = ( + b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&" + b"fields=effective_from,employment_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 Employment history and capture protected reads.""" + + def __init__(self, records: tuple[EmploymentHistoryRecord, ...]) -> None: + self.records = records + self.calls: list[tuple[UUID, UUID, datetime]] = [] + + def read_employment_history( + self, + *, + tenant_record_id: UUID, + person_record_id: UUID, + known_at: datetime, + ) -> tuple[EmploymentHistoryRecord, ...]: + """Return deterministic history for transport tests.""" + self.calls.append((tenant_record_id, person_record_id, known_at)) + return self.records + + +class ExplodingReadPort: + """Model an unexpected persistence failure without leaking its details.""" + + def read_employment_history( + self, + *, + tenant_record_id: UUID, + person_record_id: UUID, + known_at: datetime, + ) -> tuple[EmploymentHistoryRecord, ...]: + """Raise a secret-bearing error that must never reach the response body.""" + del tenant_record_id, person_record_id, known_at + raise RuntimeError("postgres password=do-not-leak") + + +def history_record( + *, + employment_record_id: UUID = EMPLOYMENT_A, + employment_record_version_id: UUID = VERSION_A, + effective_from: date = date(2026, 1, 1), + effective_to: date | None = date(2026, 7, 1), +) -> EmploymentHistoryRecord: + """Build one canonical Employment-history fixture.""" + return EmploymentHistoryRecord( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=employment_record_id, + employment_record_version_id=employment_record_version_id, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=effective_from, + effective_to=effective_to, + recorded_from=datetime(2026, 1, 1, tzinfo=timezone.utc), + recorded_to=None, + ) + + +class EmploymentHistoryHttpRouteTests(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.employment_history.read"}), + ) + self.policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="employment-history-http-v1", + resource_kind="person_employment_history", + purpose_code="employee_profile_review", + operation_code="read_record", + required_scope_code="orgmetra.people.employment_history.read", + permitted_fields=frozenset( + {"effective_from", "employment_status_code", "recorded_to"} + ), + ) + + def _app( + self, + *, + authenticator: object | None = None, + policy: object | None = None, + read_port: object | None = None, + ) -> EmploymentHistoryAsgiApp: + """Build the ASGI app with explicit injected boundaries.""" + return EmploymentHistoryAsgiApp( + 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: EmploymentHistoryAsgiApp, + *, + 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}/people/{PERSON}/employment-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"person_employment_history:{PERSON.hex}") + self.assertEqual( + payload["entries"], + [{"fields": {"effective_from": "2026-01-01", "employment_status_code": "active"}}], + ) + self.assertEqual(authenticator.tokens, ["opaque-token"]) + self.assertEqual(port.calls, [(TENANT, PERSON, 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)}/people/{PERSON}/employment-history" + max_person_path = f"/v1/tenants/{TENANT}/people/{UUID(int=(1 << 128) - 1)}/employment-history" + cases = ( + {"path": "/v1/tenants/not-a-uuid/people/not-a-uuid/employment-history"}, + {"path": zero_tenant_path}, + {"path": max_person_path}, + {"query": "known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"\xff"}, + {"query": b"bogus"}, + {"query": b"purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&purpose=other&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00+00:00&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=not-a-time&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-02-30T00:00:00Z&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=EmployeeProfileReview&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=employee_profile_review&fields="}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&fields=EffectiveFrom"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_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}/people/{PERSON}/employment-history", + f"/v1/tenants/{TENANT}/people/{PERSON}/history", + f"/v1/tenants/{TENANT}/employments/{PERSON}/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_employment_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=employee_profile_review&fields=employment_record_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 = history_record() + bad_record = EmploymentHistoryRecord( + tenant_record_id=bad_record.tenant_record_id, + person_record_id=UUID("0198a414-6000-7000-8000-000000000011"), + employment_record_id=bad_record.employment_record_id, + employment_record_version_id=bad_record.employment_record_version_id, + employment_status_code=bad_record.employment_status_code, + employment_concurrency_code=bad_record.employment_concurrency_code, + effective_from=bad_record.effective_from, + effective_to=bad_record.effective_to, + recorded_from=bad_record.recorded_from, + recorded_to=bad_record.recorded_to, + ) + status, _, payload = await self._request(self._app(read_port=FakeReadPort((bad_record,)))) + + self.assertEqual((status, payload["error_code"]), (409, "employment_history_integrity_conflict")) + self.assertNotIn("employment_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-employment-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 ae0b665ce492949177e923a9dc4c21b91b40b768 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:59:08 +0900 Subject: [PATCH 02/27] feat(people): expose employment history HTTP read --- .../src/orgmetra_people_api/__init__.py | 2 + .../employment_history_http.py | 283 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 services/people-api/src/orgmetra_people_api/employment_history_http.py diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index 8486d5552..1899ef30a 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -15,6 +15,7 @@ EmploymentHistoryRecord, read_employment_history, ) +from orgmetra_people_api.employment_history_http import EmploymentHistoryAsgiApp from orgmetra_people_api.hire import ( HireAcceptanceCommand, HireAcceptancePort, @@ -61,6 +62,7 @@ "AuthorizedEmploymentHistoryView", "AuthorizedWorkerPeopleView", "EmploymentHistoryIntegrityError", + "EmploymentHistoryAsgiApp", "EmploymentHistoryReadPort", "EmploymentHistoryRecord", "EmploymentMutationCommand", diff --git a/services/people-api/src/orgmetra_people_api/employment_history_http.py b/services/people-api/src/orgmetra_people_api/employment_history_http.py new file mode 100644 index 000000000..a62d82bc7 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/employment_history_http.py @@ -0,0 +1,283 @@ +"""Dependency-light ASGI route for governed Employment-history reads. + +The transport adapter owns request parsing and client-safe responses. The +Employment-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.employment_history import ( + EmploymentHistoryIntegrityError, + EmploymentHistoryReadPort, + read_employment_history, +) +from orgmetra_people_api.http import ( + AsgiReceive, + AsgiSend, + _authorization_header, + _send_json as _emit_json, +) + +_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 Employment-history route input that must fail closed.""" + + +@dataclass(frozen=True, slots=True) +class _ParsedEmploymentHistoryRequest: + """Hold validated path and query values for one Employment-history read.""" + + tenant_record_id: UUID + person_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( + "Employment-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 EmploymentHistoryAsgiApp: + """Expose one tenant-scoped, read-only Employment-history route. + + Supported route:: + + GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history + ?known_at=YYYY-MM-DDTHH:MM:SSZ + &purpose=employee_profile_review + &fields=effective_from,employment_status_code + + The app contains no web-framework dependency and returns only the + purpose-authorized Employment-history fields from the governed service. + """ + + authenticator: TokenAuthenticator + policy: PurposeBoundAccessPolicy + read_port: EmploymentHistoryReadPort + + 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, EmploymentHistoryReadPort): + raise TypeError("read_port must implement EmploymentHistoryReadPort") + + 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("EmploymentHistoryAsgiApp 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 Employment-history read route.", + extra_headers=((b"allow", b"GET"),), + ) + return + + path = scope.get("path") + if not isinstance(path, str) or not _looks_like_employment_history_route(path): + await _send_error( + send, + status=404, + error_code="route_not_found", + message="Use /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history.", + ) + return + + try: + request = _parse_employment_history_request(path, scope.get("query_string", b"")) + except _InvalidHttpRequest: + await _send_error( + send, + status=400, + error_code="invalid_request", + message="Correct the tenant/Person 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_employment_history( + principal=principal, + tenant_record_id=request.tenant_record_id, + person_record_id=request.person_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 Person Employment history.", + ) + return + except EmploymentHistoryIntegrityError: + await _send_error( + send, + status=409, + error_code="employment_history_integrity_conflict", + message="The Employment 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_employment_history_route(path: str) -> bool: + """Recognize the versioned Employment-history route before parsing IDs.""" + parts = path.strip("/").split("/") + return ( + len(parts) == 6 + and tuple(parts[:2]) == _ROUTE_PREFIX + and parts[3] == "people" + and parts[5] == "employment-history" + ) + + +def _parse_employment_history_request(path: str, raw_query: object) -> _ParsedEmploymentHistoryRequest: + """Validate all caller-controlled path/query values before authentication.""" + parts = path.strip("/").split("/") + try: + tenant_record_id = UUID(parts[2]) + person_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 person_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 _ParsedEmploymentHistoryRequest( + tenant_record_id=tenant_record_id, + person_record_id=person_record_id, + known_at=known_at, + purpose_code=purpose_code, + requested_fields=frozenset(raw_fields), + ) From 094466db22a707185fd2de06a1da7c228d7d6c3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 11:59:16 +0900 Subject: [PATCH 03/27] docs: publish employment history HTTP contract --- .../employment-history-http-quality.yml | 66 ++++++++++++++ CHANGELOG.md | 1 + docs/API_CONTRACT.md | 5 +- docs/SECURITY.md | 10 +++ docs/TEST_STRATEGY.md | 1 + docs/TRACEABILITY.md | 1 + docs/UML.md | 28 +++++- docs/adr/0155-employment-history-http-read.md | 63 +++++++++++++ docs/adr/README.md | 1 + ...employment-history-http-read-references.md | 35 ++++++++ .../employment-history-http-read.md | 45 ++++++++++ manifest.json | 2 +- schemas/openapi.yaml | 90 +++++++++++++++++++ scripts/foundation-contract-core.mjs | 18 ++++ services/people-api/README.md | 2 + tests/openapi-contract.test.mjs | 20 +++++ tests/validate_repository.py | 39 ++++++++ 17 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/employment-history-http-quality.yml create mode 100644 docs/adr/0155-employment-history-http-read.md create mode 100644 docs/doctoring/employment-history-http-read-references.md create mode 100644 docs/traceability/employment-history-http-read.md diff --git a/.github/workflows/employment-history-http-quality.yml b/.github/workflows/employment-history-http-quality.yml new file mode 100644 index 000000000..a63a8d0af --- /dev/null +++ b/.github/workflows/employment-history-http-quality.yml @@ -0,0 +1,66 @@ +name: Employment History HTTP Quality + +on: + pull_request: + branches: + - develop + - feat/people-employment-history-read + paths: + - "services/people-api/**" + - "packages/hris-kernel/**" + - "packages/keyverse-adapter/**" + - "schemas/openapi.yaml" + - ".github/requirements/foundation-test.txt" + - ".github/workflows/employment-history-http-quality.yml" + - "docs/API_CONTRACT.md" + - "docs/SECURITY.md" + - "docs/TEST_STRATEGY.md" + - "docs/TRACEABILITY.md" + - "docs/adr/0155-employment-history-http-read.md" + - "docs/doctoring/employment-history-http-read-references.md" + - "docs/traceability/employment-history-http-read.md" + - "services/people-api/README.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: employment-history-http-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Employment-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-employment-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 99f4752d7..a664c3e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Orgmetra will be documented in this file. ### Added - 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 stacked PR #155 adds the customer-callable `EmploymentHistoryAsgiApp` read route, `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history`, with exact UTC knowledge-cutoff parsing, purpose/field authorization through the existing Employment-history service, minimized entries, no-store response controls, client-safe errors, a published OpenAPI 3.2 contract, and a dedicated exact-head 100% People API quality workflow. It does not mutate Employment or make an employment decision. - 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. - Active performance-criterion scope hardening: `criterion_observation_scope_guard` rejects criterion outcomes for a Job the worker did not effectively hold at the observation date, observations before the relevant assignment, and observations outside the referenced performance cycle while preserving valid multiple-assignment cases and existing bitemporal correction semantics. The guard evaluates current-recorded facts, derives the date coordinate from `observed_at` in UTC so session `TimeZone` cannot alter the result, uses a trusted function search path, and adds no PII or automated employment decision authority. The Foundation PostgreSQL contract also rejects a closed `recorded_to` on each time-coordinate lookup and proves UTC midnight plus non-UTC session `TimeZone` boundaries. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 27235d9c9..b43d2d1c5 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -52,6 +52,7 @@ POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candida POST /v1/employment-records POST /v1/position-records POST /v1/assignment-records +GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history?known_at=...&purpose=...&fields=... POST /v1/job-profiles POST /v1/job-profiles/{job_profile_id}/publish POST /v1/candidate-profiles @@ -60,7 +61,7 @@ POST /v1/criterion-observations 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. +The foundation OpenAPI contract covers the shared command vocabulary and baseline person, employment, position, assignment, job-profile, selection-decision, and purpose-bound Employment-history read 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. The Employment-history read requires `orgmetra.people.employment_history.read`, an exact `known_at` UTC cutoff, a business purpose, and an explicit field list; it returns only policy-authorized entries and never mutates HRIS truth. ## Error shape @@ -73,4 +74,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 fd6dd3ea6..f474fde2a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -39,6 +39,16 @@ Evaluation fails closed unless request, actor, resource, and policy tenants all Authorization evidence contains only governance metadata, including the opaque actor and exact target-resource references, plus field names, never protected values. A denial returns a stable reason code and next safe action. An allow decision returns only the exact requested field subset, not every field the policy could permit. Both allow and denial evidence preserve the exact target reference so immutable audit correlation cannot collapse distinct person or employment records into one resource-kind-level event. These rules implement the Orgmetra side of the NIST SP 800-162 ABAC shape and attribute-integrity principles from NIST SP 800-205; ADR 0008 records the boundary. +The Employment-history HTTP read applies this boundary at the customer route: +`GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history`. +It validates the tenant, Person, UTC `known_at`, purpose, and explicit fields +before authentication; then the authenticated principal and +`orgmetra.people.employment_history.read` scope are evaluated by the existing +Employment-history service. The response contains only authorized +`entries[].fields`, uses `Cache-Control: no-store` and `Vary: Authorization`, +and maps integrity or unexpected backend failures to client-safe opaque support +references. The route adds no mutation or employment-decision authority. + ## Mutation security contract Every mutating HTTP operation and its server-side command handler requires one validated `Idempotency-Key` that crosses the command boundary into durable transactional replay state. The published OpenAPI employment, position, assignment, person, job-profile, and selection-decision command families require `X-Tenant-Reference`, `X-Actor-Reference`, and `X-Purpose-Code`; those values must match the authenticated Keyverse principal and the operation-specific least-privilege scope. The executable People mutation handlers added on this branch currently implement employment, position, and assignment creation with those headers. Person, job-profile, and selection-decision remain published foundation API contracts until their server handlers are integrated; their OpenAPI presence is not runtime evidence. Confirmed-hire materialization instead binds the tenant in `/v1/tenants/{tenant_record_id}/candidate-worker-conversions`, the business purpose in its exact query parameter, and the actor through the authenticated principal. It does not accept weaker duplicate actor/tenant/purpose header authorities. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index c20813b72..850c701ff 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 | +| Employment-history HTTP read parsing, authentication order, purpose/field authorization, bitemporal cutoff forwarding, response minimization, client-safe errors, and exact 100% statement/branch coverage | `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` and `.github/workflows/employment-history-http-quality.yml` | | 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 22a4178fe..8b21cb5d4 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -14,6 +14,7 @@ | Evidence-backed human selection decisions | Talent Acquisition | `decision_evidence_set`, `selection_decision_evidence`, `selection_decision` | database-owned SHA-256 sealing, non-empty evidence, drift/reuse rejection, OpenAPI human-confirmation tests | ADR-0001 | implemented_on_active_pr | | 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 | +| Purpose-bound Employment-history HTTP read | People API / customer read boundary | `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history`, `EmploymentHistoryAsgiApp`, `read_employment_history()`, `EmploymentHistoryReadPort` | exact path/query validation before authentication; Keyverse scope and purpose/field authorization; UTC bitemporal cutoff; minimized entries; client-safe 400/401/403/409/500 errors; exact 100% People API statement/branch coverage | ADR-0008, ADR-0149, ADR-0155 | implemented_on_active_pr | | 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 | | 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 | diff --git a/docs/UML.md b/docs/UML.md index efc8b23d6..476eaa773 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -46,6 +46,32 @@ 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. +## Employment-history read sequence + +```mermaid +sequenceDiagram + actor HRUser as Authorized HR user + participant Gateway + participant PeopleHTTP as EmploymentHistoryAsgiApp + participant Auth as Keyverse authentication + participant Service as Employment-history service + participant Store as Injected read port + + HRUser->>Gateway: GET Person Employment history(tenant, known_at, purpose, fields) + Gateway->>PeopleHTTP: Forward one versioned request + PeopleHTTP->>PeopleHTTP: Validate route/query before authentication + PeopleHTTP->>Auth: Authenticate one Bearer credential + Auth-->>PeopleHTTP: Principal and operation scope + PeopleHTTP->>Service: Authorize exact target and requested fields + Service->>Store: Read tenant/Person history at known_at + Store-->>Service: Bitemporal Employment versions + Service-->>PeopleHTTP: Authorized entries only + PeopleHTTP-->>HRUser: No-store JSON response or safe error +``` + +The route is read-only and does not query another service's application tables, +mutate Employment truth, or make an employment decision. + ## Selection decision sequence ```mermaid @@ -119,4 +145,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/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md new file mode 100644 index 000000000..67c7250c3 --- /dev/null +++ b/docs/adr/0155-employment-history-http-read.md @@ -0,0 +1,63 @@ +# ADR 0155: Expose governed Employment history through a read-only HTTP boundary + +- **Status:** Proposed on active stacked PR #155; 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 0149 (Employment-history read) + +## Context + +PR #149 defines the purpose-bound Employment-history use case, but it does not +expose a customer-callable transport route. Deployments need one stable HTTP +boundary that preserves the same tenant, Person, purpose, field, bitemporal, +and no-disclosure controls without adding Employment mutation or employment- +decision authority. + +## Decision + +Add `EmploymentHistoryAsgiApp` with this route: + +```text +GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history + ?known_at=YYYY-MM-DDTHH:MM:SSZ + &purpose=employee_profile_review + &fields=effective_from,employment_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_employment_history()`. The operation declares +`orgmetra.people.employment_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, Employment-history response, +scope, and 400/401/403/409 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 Employment-history boundary. +- Existing Employment-history service ownership remains responsible for + purpose-bound authorization, bitemporal validation, and persistence access. +- 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, export, writes, + cross-service joins, or high-impact employment decisions; each requires a + separate contract. + +## Verification + +The test-only child head `6c2d6b89` fails during collection while the HTTP +adapter module is absent. The implementation retains that test-first chain and +must remain a Draft stacked PR until independent review and all protected +central gates are authoritative. + +RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and +PostgreSQL temporal/read-boundary guidance inform this transport decision. They +are defense-in-depth references, not certification or merge evidence. diff --git a/docs/adr/README.md b/docs/adr/README.md index 099a21139..cac94e4e3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,3 +16,4 @@ | [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 | +| [0155](0155-employment-history-http-read.md) | Expose governed Employment history through a read-only HTTP boundary | Proposed on active stacked PR #155 | diff --git a/docs/doctoring/employment-history-http-read-references.md b/docs/doctoring/employment-history-http-read-references.md new file mode 100644 index 000000000..8317ce2a6 --- /dev/null +++ b/docs/doctoring/employment-history-http-read-references.md @@ -0,0 +1,35 @@ +# Employment-history HTTP read references + +**Scope:** Standards basis for active PR #155. This file does not claim certification or protected-main integration. + +## APA 7 references + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). RFC Editor. https://doi.org/10.17487/RFC3339 + +National Institute of Standards and Technology. (2020). *Zero trust architecture* (NIST Special Publication 800-207). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-207 + +National Institute of Standards and Technology. (2023). *A zero trust architecture model for access control in cloud-native applications in multi-cloud environments* (NIST Special Publication 800-207A). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-207A + +OpenAPI Initiative. (2025). *OpenAPI Specification v3.2.0*. https://spec.openapis.org/oas/v3.2.0 + +PostgreSQL Global Development Group. (2025). *PostgreSQL 18 documentation: Date/time types*. https://www.postgresql.org/docs/18/datatype-datetime.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. NIST zero-trust guidance informs +explicit identity/resource authorization at the HTTP boundary. PostgreSQL +date/time semantics support the repository's separation of business dates from +timezone-aware system-recorded instants; the service remains responsible for +the complete bitemporal contract. + +These references constrain the transport adapter only. They do not authorize +scope expansion into compensation, performance, candidate, credential, or +automated employment-decision data. + +## Review date + +Rechecked as current primary references on 2026-08-30. Re-review if the +published OpenAPI major version, supported PostgreSQL major version, or cited +NIST authorization guidance changes. diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md new file mode 100644 index 000000000..494e4c592 --- /dev/null +++ b/docs/traceability/employment-history-http-read.md @@ -0,0 +1,45 @@ +# Employment-history HTTP read traceability + +**Lifecycle status:** Active stacked PR #155 only. This document does not claim protected-`develop` integration. + +## Buyer problem + +PR #149 defines an authorized Employment-history read, but 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 | `EmploymentHistoryAsgiApp` 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.employment_history.read` plus `read_employment_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 Employment-history service | call capture and service bitemporal tests | +| Minimize the response | `resource_reference` plus authorized `entries[].fields` only | successful and empty-result response assertions; no Person/Position/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, response 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:** `6c2d6b89` adds HTTP regressions while `orgmetra_people_api.employment_history_http` is absent. +2. **Expected RED:** focused collection fails with `ModuleNotFoundError` at that owning module boundary; this is distinct from a 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 Employment-version fields and the already- +governed Person/Employment lineage. It does not join Position, 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. +- Employment 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 97f2bab14..9a7e571b0 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":"c68893c18d6e7c4b118f977a27fe6a6e7e46b04268225dc8b1a550c9696c5d5a","bytes":17811,"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":"8857996b3a0d9cb6c970bec495cbba6b5d7859fd03bafc1237fa5fff695eabc4","bytes":4944,"lines":77},{"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":"70f073f3f9b86f824efc04706f2a299bfd77e7c2d23d5999eba3c06691e39186","bytes":11860,"lines":74},{"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":"2b89cc75fcdec89cdc390bbd26e1653d17d6b5699596fdd14c38ff695ee7ab6d","bytes":16976,"lines":136},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"8fe78c72fd45fb28af64da727e41b9164c61052331cb1a12fe9c1d8a10b795ca","bytes":12006,"lines":41},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"cf0f41fa4b783ae77607ba5d21076bf8f2af92b975e49e6585b112211ce949fa","bytes":6625,"lines":148},{"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":"820fd9556036344e39012f1ddd1b70b6593ea6dd02b8adaefaaa15f8ed3d1815","bytes":1995,"lines":19},{"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":"a0513cc34b33bb2deedada4b706daeeba69c5239af16a586639a90ab62661f3d","bytes":32417,"lines":1110},{"path":"scripts/foundation-contract-core.mjs","sha256":"beb4c251f912c687ff08d01cf6791d66ac7d8e3f0d767d857a8f164b190c3040","bytes":29406,"lines":707},{"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":"cf0269695fb3c3ceb9ea1fa1bcddb463e56a64753b0f953200b7609f8be7dfb0","bytes":7170,"lines":215},{"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":"7ea0d34c61b921e9d7c3566b5095604e72b67cca5924497a0b6ce73bd01452db","bytes":28737,"lines":677}]} diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 0fd397e92..45a9ff9f1 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -344,6 +344,64 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/SnapshotNotFound' + /tenants/{tenant_record_id}/people/{person_record_id}/employment-history: + get: + operationId: readEmploymentHistory + summary: Read purpose-bound bitemporal Employment history for one Person + tags: + - people-core + security: + - keyverse_oidc: + - orgmetra.people.employment_history.read + parameters: + - name: tenant_record_id + in: path + required: true + schema: + type: string + format: uuid + - name: person_record_id + in: path + required: true + schema: + type: string + format: uuid + - name: known_at + in: query + required: true + description: System-time knowledge cutoff for the half-open recorded interval. + schema: + type: string + format: date-time + - name: purpose + in: query + required: true + description: Business purpose evaluated by the purpose-bound authorization policy. + schema: + type: string + pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + - name: fields + in: query + required: true + description: Comma-separated, explicitly requested Employment-history fields. + schema: + type: string + pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*(,[a-z][a-z0-9]*(?:_[a-z0-9]+)*)*$' + responses: + '200': + description: Authorized Employment-history entries ordered by effective date. + content: + application/json: + schema: + $ref: '#/components/schemas/EmploymentHistoryResponse' + '400': + $ref: '#/components/responses/InvalidCommand' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/EmploymentHistoryIntegrityConflict' components: securitySchemes: keyverse_oidc: @@ -669,6 +727,32 @@ components: assignment_record_id: type: string format: uuid + EmploymentHistoryResponse: + type: object + additionalProperties: false + required: + - resource_reference + - entries + properties: + resource_reference: + type: string + pattern: '^person_employment_history:[0-9a-f]{32}$' + entries: + type: array + items: + $ref: '#/components/schemas/EmploymentHistoryEntry' + EmploymentHistoryEntry: + type: object + additionalProperties: false + required: + - fields + properties: + fields: + type: object + additionalProperties: + oneOf: + - type: string + - type: 'null' JobAnalysisEvidenceSource: type: object additionalProperties: false @@ -1012,6 +1096,12 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + EmploymentHistoryIntegrityConflict: + description: The Employment-history evidence failed its trusted bitemporal integrity checks. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' UnsupportedMediaType: description: The job-analysis POST body is missing application/json media type. content: diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 1e9fb267c..13eb7954c 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -539,6 +539,24 @@ export function validateOpenApiContract(openapiText) { requireWithin(errors, 'readJobAnalysisSnapshot', jobAnalysisRead, ' - orgmetra.job_architecture.read', 'least-privilege read scope'); } + const employmentHistoryRead = extractYamlBlock( + openapiText, + ' /tenants/{tenant_record_id}/people/{person_record_id}/employment-history:' + ); + if (!employmentHistoryRead) { + errors.push('readEmploymentHistory: path block is missing'); + } else { + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, 'operationId: readEmploymentHistory', 'operationId'); + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, ' - orgmetra.people.employment_history.read', 'least-privilege read scope'); + for (const parameterName of ['tenant_record_id', 'person_record_id', 'known_at', 'purpose', 'fields']) { + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, ` - name: ${parameterName}`, `required ${parameterName} parameter`); + } + for (const responseCode of [" '200':", " '400':", " '401':", " '403':", " '409':"]) { + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, responseCode, `response ${responseCode.trim()}`); + } + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, "$ref: '#/components/schemas/EmploymentHistoryResponse'", 'response schema'); + } + 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 548a83446..a01a0eabc 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. +`EmploymentHistoryAsgiApp` exposes the governed Employment-history use case as `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history?known_at=YYYY-MM-DDTHH:MM:SSZ&purpose=employee_profile_review&fields=...`. It validates the exact path and query shape before authentication, delegates the tenant/Person/purpose/field decision and bitemporal snapshot to `read_employment_history()`, and returns only authorized `entries[].fields` with `Cache-Control: no-store` and `Vary: Authorization`. The route is read-only: it does not query another service's application tables, mutate Employment, or make an employment decision. + 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/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 8e98078da..9689aaa82 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -166,6 +166,26 @@ for (const testCase of [ fragment: ' - confirmation_reference\n', occurrence: 5, expected: /CreateAssignmentRecordCommand.*confirmation/ + }, + { + name: 'readEmploymentHistory path', + fragment: ' /tenants/{tenant_record_id}/people/{person_record_id}/employment-history:\n', + expected: /readEmploymentHistory.*path block/ + }, + { + name: 'readEmploymentHistory scope', + fragment: ' - orgmetra.people.employment_history.read\n', + expected: /readEmploymentHistory.*scope/ + }, + { + name: 'readEmploymentHistory known_at parameter', + fragment: ' - name: known_at\n', + expected: /readEmploymentHistory.*known_at parameter/ + }, + { + name: 'readEmploymentHistory response schema', + fragment: " $ref: '#/components/schemas/EmploymentHistoryResponse'\n", + expected: /readEmploymentHistory.*response schema/ } ]) { test(`structural OpenAPI gate rejects missing ${testCase.name}`, () => { diff --git a/tests/validate_repository.py b/tests/validate_repository.py index fe0a329ff..93bff6fec 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -535,6 +535,45 @@ def _validate_openapi_contract() -> None: "least-privilege read scope", ) + employment_history_read_block = _yaml_block( + openapi, + " /tenants/{tenant_record_id}/people/{person_record_id}/employment-history:", + ) + if not employment_history_read_block: + _fail("readEmploymentHistory: path block is missing") + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + "operationId: readEmploymentHistory", + "operationId", + ) + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + " - orgmetra.people.employment_history.read", + "least-privilege read scope", + ) + for parameter_name in ("tenant_record_id", "person_record_id", "known_at", "purpose", "fields"): + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + f" - name: {parameter_name}", + f"required {parameter_name} parameter", + ) + for response in (" '200':", " '400':", " '401':", " '403':", " '409':"): + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + response, + f"response {response.strip()}", + ) + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + "$ref: '#/components/schemas/EmploymentHistoryResponse'", + "response schema", + ) + for schema_name in ( "CreateJobProfileCommand", "RecordSelectionDecisionCommand", From ec2389f47f2ed6b0b10ee0a7ce5d931770a09dcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 10:08:13 +0900 Subject: [PATCH 04/27] fix(ci): retire stale employment-history leaf workflow --- .../employment-history-http-quality.yml | 66 ------------------- 1 file changed, 66 deletions(-) delete mode 100644 .github/workflows/employment-history-http-quality.yml diff --git a/.github/workflows/employment-history-http-quality.yml b/.github/workflows/employment-history-http-quality.yml deleted file mode 100644 index a63a8d0af..000000000 --- a/.github/workflows/employment-history-http-quality.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: Employment History HTTP Quality - -on: - pull_request: - branches: - - develop - - feat/people-employment-history-read - paths: - - "services/people-api/**" - - "packages/hris-kernel/**" - - "packages/keyverse-adapter/**" - - "schemas/openapi.yaml" - - ".github/requirements/foundation-test.txt" - - ".github/workflows/employment-history-http-quality.yml" - - "docs/API_CONTRACT.md" - - "docs/SECURITY.md" - - "docs/TEST_STRATEGY.md" - - "docs/TRACEABILITY.md" - - "docs/adr/0155-employment-history-http-read.md" - - "docs/doctoring/employment-history-http-read-references.md" - - "docs/traceability/employment-history-http-read.md" - - "services/people-api/README.md" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: employment-history-http-quality-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - unit: - name: Employment-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-employment-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)" From 475e46c2264c47057e85722f0c30c920776c4a03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 10:09:40 +0900 Subject: [PATCH 05/27] docs(adr): align employment-history HTTP validation owner --- docs/adr/0155-employment-history-http-read.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/adr/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md index 67c7250c3..e6312e538 100644 --- a/docs/adr/0155-employment-history-http-read.md +++ b/docs/adr/0155-employment-history-http-read.md @@ -36,9 +36,11 @@ authentication, authorization, integrity, and unexpected failures to the published client-safe error envelope. OpenAPI publishes the route, query/path parameters, Employment-history response, -scope, and 400/401/403/409 responses. The dedicated workflow checks the exact -PR head, compiles the service, and runs the complete People suite at 100% -statement and branch coverage. +scope, and 400/401/403/409 responses. Repository-owned acceptance is executed by +the canonical Foundation CI after this stacked lane returns to a protected- +`develop` pull-request boundary. The historical feature-local Employment-history +workflow is retired; its service tests remain part of the complete People suite +and retain the exact 100% statement and branch coverage requirement. ## Consequences @@ -50,13 +52,18 @@ statement and branch coverage. - The route intentionally does not add pagination, export, writes, cross-service joins, or high-impact employment decisions; each requires a separate contract. +- Current #149 contains later application-integrity repairs and protected #161 + workflow consolidation. This PR must adopt that parent through ordinary + non-force reconciliation before any protected-integration claim. ## Verification The test-only child head `6c2d6b89` fails during collection while the HTTP -adapter module is absent. The implementation retains that test-first chain and -must remain a Draft stacked PR until independent review and all protected -central gates are authoritative. +adapter module is absent. The implementation retains that test-first chain. +Historical evidence from the pre-consolidation feature head does not transfer to +the current branch. After semantic parent reconciliation, the resulting exact +head must run the canonical Foundation, security, CodeQL, model-review, and +independent-review gates before integration. RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and PostgreSQL temporal/read-boundary guidance inform this transport decision. They From b4f69060430bb355b71de202001637b888215554 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 10:09:53 +0900 Subject: [PATCH 06/27] docs(traceability): record HTTP workflow-owner repair --- .../employment-history-http-read.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md index 494e4c592..92dba67b9 100644 --- a/docs/traceability/employment-history-http-read.md +++ b/docs/traceability/employment-history-http-read.md @@ -20,14 +20,25 @@ surface. | Minimize the response | `resource_reference` plus authorized `entries[].fields` only | successful and empty-result response assertions; no Person/Position/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, response 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 | +| Keep evidence on the exact candidate | canonical Foundation checks out the PR head and executes the complete People suite when the stack returns to a protected-`develop` PR boundary | compile, exact 100% statement/branch coverage, repository validation, and clean checkout | ## Test-first chain 1. **Contract-only child head:** `6c2d6b89` adds HTTP regressions while `orgmetra_people_api.employment_history_http` is absent. 2. **Expected RED:** focused collection fails with `ModuleNotFoundError` at that owning module boundary; this is distinct from a 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. +3. **Implementation:** add the smallest separate ASGI adapter, package-root export, OpenAPI contract, and structural OpenAPI regressions. +4. **Repository-quality repair:** protected #161 consolidated repository-owned validation into Foundation. Current branch commit `ec2389f47f2ed6b0b10ee0a7ce5d931770a09dcf` retires the obsolete Employment-history leaf workflow instead of recreating a second owner. +5. **Required verification:** ordinary non-force reconciliation with current #149, deterministic manifest reseal from the resolved bytes, then exact-head Foundation/security/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation results do not transfer. + +## Stack authority + +Current canonical Employment-history application owner #149 is +`93f415922592734d9d4ba3afb69a8633ecb3de15` on #55. This HTTP branch was +created from older #149 snapshot `44c83128701f1985f8566b39cbf837c7b20f0111`. +The HTTP feature remains valid, but its overlapping changelog, manifest, +repository validators, People README/exports, and OpenAPI provenance must be +semantically reconciled with current parent truth before integration. The +retired leaf workflow must remain absent throughout that reconciliation. ## Security and data boundary From 51c7ab293efadbcee7b6a5e433b666a7f406dd49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:19:04 +0900 Subject: [PATCH 07/27] docs: record employment-history HTTP reconciliation --- .../employment-history-http-read.md | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md index 92dba67b9..d16a96fa5 100644 --- a/docs/traceability/employment-history-http-read.md +++ b/docs/traceability/employment-history-http-read.md @@ -22,23 +22,31 @@ surface. | Publish the same customer contract | OpenAPI route, parameters, response schema, scope, and responses | Python/Node structural OpenAPI mutation tests | | Keep evidence on the exact candidate | canonical Foundation checks out the PR head and executes the complete People suite when the stack returns to a protected-`develop` PR boundary | compile, exact 100% statement/branch coverage, repository validation, and clean checkout | -## Test-first chain +## Test-first and reconciliation chain -1. **Contract-only child head:** `6c2d6b89` adds HTTP regressions while `orgmetra_people_api.employment_history_http` is absent. -2. **Expected RED:** focused collection fails with `ModuleNotFoundError` at that owning module boundary; this is distinct from a missing dependency-path invocation. -3. **Implementation:** add the smallest separate ASGI adapter, package-root export, OpenAPI contract, and structural OpenAPI regressions. -4. **Repository-quality repair:** protected #161 consolidated repository-owned validation into Foundation. Current branch commit `ec2389f47f2ed6b0b10ee0a7ce5d931770a09dcf` retires the obsolete Employment-history leaf workflow instead of recreating a second owner. -5. **Required verification:** ordinary non-force reconciliation with current #149, deterministic manifest reseal from the resolved bytes, then exact-head Foundation/security/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation results do not transfer. +1. **Contract-only child head:** `6c2d6b89` added HTTP regressions while `orgmetra_people_api.employment_history_http` was absent. +2. **Expected RED:** focused collection failed with `ModuleNotFoundError` at that owning module boundary; this is distinct from a missing dependency-path invocation. +3. **Implementation:** the smallest separate ASGI adapter, package-root export, OpenAPI contract, and structural OpenAPI regressions were added. +4. **Repository-quality repair:** protected #161 consolidated repository-owned validation into Foundation. Commit `ec2389f47f2ed6b0b10ee0a7ce5d931770a09dcf` retired the obsolete Employment-history leaf workflow instead of recreating a second owner. +5. **Parent reconciliation:** ordinary two-parent merge `32ce60e60d718f4007dc4bc0a42d370e5470b60f` adopted current #149 `93f415922592734d9d4ba3afb69a8633ecb3de15` without force-push or destructive rebase while preserving the complete HTTP/OpenAPI/security/docs/tests delta. +6. **Provenance repair:** the merge removed the obsolete Job Analysis leaf-workflow requirement inherited from the pre-#161 child validators, corrected stale leaf-workflow claims in `CHANGELOG.md`, `docs/TEST_STRATEGY.md`, and the People README, and resealed `manifest.json` from the resolved required-artifact bytes. +7. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation and predecessor-head results do not transfer. ## Stack authority -Current canonical Employment-history application owner #149 is -`93f415922592734d9d4ba3afb69a8633ecb3de15` on #55. This HTTP branch was -created from older #149 snapshot `44c83128701f1985f8566b39cbf837c7b20f0111`. -The HTTP feature remains valid, but its overlapping changelog, manifest, -repository validators, People README/exports, and OpenAPI provenance must be -semantically reconciled with current parent truth before integration. The -retired leaf workflow must remain absent throughout that reconciliation. +Canonical Employment-history application owner #149 is +`93f415922592734d9d4ba3afb69a8633ecb3de15` on #55. The HTTP branch was +originally created from snapshot `44c83128701f1985f8566b39cbf837c7b20f0111`. +Merge `32ce60e60d718f4007dc4bc0a42d370e5470b60f` makes the current HTTP lineage a +true descendant of the live #149 owner: comparison against that parent is +0-behind and contains only the HTTP transport, OpenAPI, security, documentation, +tests, and deterministic provenance delta. The retired feature-local workflow +remains absent. + +The merge also repaired a third pre-consolidation documentation claim that was +found during reconciliation: the inherited People README still described a +standalone People API quality workflow. It now names canonical Foundation as the +repository-owned acceptance path instead of reviving a duplicate workflow. ## Security and data boundary From 2bcf5586745b57b19b65a9b9c801497a409c3b66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:34:17 +0900 Subject: [PATCH 08/27] test(people): pin Employment-history HTTP boundary failures --- ...loyment_history_http_boundary_hardening.py | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 services/people-api/tests/test_employment_history_http_boundary_hardening.py diff --git a/services/people-api/tests/test_employment_history_http_boundary_hardening.py b/services/people-api/tests/test_employment_history_http_boundary_hardening.py new file mode 100644 index 000000000..64855a6a1 --- /dev/null +++ b/services/people-api/tests/test_employment_history_http_boundary_hardening.py @@ -0,0 +1,163 @@ +"""Transport-boundary regressions for Employment-history reads.""" + +from __future__ import annotations + +import json +import unittest +from datetime import datetime, timezone +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api import AuthenticatedPrincipal, EmploymentHistoryRecord +from orgmetra_people_api.employment_history_http import EmploymentHistoryAsgiApp + +TENANT = UUID("0198a414-6000-7000-8000-000000000001") +PERSON = UUID("0198a414-6000-7000-8000-000000000010") +DEFAULT_QUERY = ( + b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&" + b"fields=effective_from,employment_status_code" +) + + +class RecordingAuthenticator: + """Return one configured value or raise one configured backend error.""" + + def __init__(self, result: object, *, error: Exception | None = None) -> None: + self.result = result + self.error = error + self.tokens: list[str] = [] + + async def authenticate(self, bearer_token: str) -> object: + """Record the opaque token without exposing it in failures.""" + self.tokens.append(bearer_token) + if self.error is not None: + raise self.error + return self.result + + +class EmptyHistoryPort: + """Return no protected rows while recording persistence calls.""" + + def __init__(self) -> None: + self.calls: list[tuple[UUID, UUID, datetime]] = [] + + def read_employment_history( + self, + *, + tenant_record_id: UUID, + person_record_id: UUID, + known_at: datetime, + ) -> tuple[EmploymentHistoryRecord, ...]: + """Return an immutable empty history for transport-boundary tests.""" + self.calls.append((tenant_record_id, person_record_id, known_at)) + return () + + +class EmploymentHistoryHttpBoundaryHardeningTests(unittest.IsolatedAsyncioTestCase): + """Keep caller-controlled transport work bounded and identity failures client-safe.""" + + def setUp(self) -> None: + self.principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({"orgmetra.people.employment_history.read"}), + ) + self.policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="employment-history-http-v1", + resource_kind="person_employment_history", + purpose_code="employee_profile_review", + operation_code="read_record", + required_scope_code="orgmetra.people.employment_history.read", + permitted_fields=frozenset({"effective_from", "employment_status_code"}), + ) + + def _app(self, authenticator: RecordingAuthenticator, port: EmptyHistoryPort) -> EmploymentHistoryAsgiApp: + """Build the route with explicit test doubles at external boundaries.""" + return EmploymentHistoryAsgiApp( + authenticator=authenticator, + policy=self.policy, + read_port=port, + ) + + async def _request( + self, + app: EmploymentHistoryAsgiApp, + *, + path: str | None = None, + query: object = DEFAULT_QUERY, + ) -> tuple[int, dict[str, object]]: + """Execute one dependency-light ASGI request and decode its response.""" + scope = { + "type": "http", + "method": "GET", + "path": path + if path is not None + else f"/v1/tenants/{TENANT}/people/{PERSON}/employment-history", + "query_string": query, + "headers": [(b"authorization", b"Bearer opaque-token")], + } + messages: list[dict[str, object]] = [] + + async def receive() -> dict[str, object]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + await app(scope, receive, send) + start, body = messages + return int(start["status"]), json.loads(bytes(body["body"])) + + async def test_authentication_backend_failure_is_client_safe_and_skips_persistence(self) -> None: + """An identity-backend exception must not escape the ASGI boundary or leak secrets.""" + authenticator = RecordingAuthenticator( + self.principal, + error=RuntimeError("oidc client_secret=do-not-leak"), + ) + port = EmptyHistoryPort() + + status, payload = await self._request(self._app(authenticator, port)) + + self.assertEqual((status, payload["error_code"]), (500, "internal_error")) + self.assertNotIn("client_secret", json.dumps(payload)) + self.assertEqual(authenticator.tokens, ["opaque-token"]) + self.assertEqual(port.calls, []) + + async def test_noncanonical_authenticator_result_is_client_safe_and_skips_persistence(self) -> None: + """A structurally arbitrary principal cannot cross the authenticated identity boundary.""" + authenticator = RecordingAuthenticator(object()) + port = EmptyHistoryPort() + + status, payload = await self._request(self._app(authenticator, port)) + + self.assertEqual((status, payload["error_code"]), (500, "internal_error")) + self.assertEqual(port.calls, []) + + async def test_oversized_path_fails_before_authentication(self) -> None: + """Reject oversized route material before invoking the identity backend.""" + authenticator = RecordingAuthenticator(self.principal) + port = EmptyHistoryPort() + path = "/v1/tenants/" + ("a" * 300) + f"/people/{PERSON}/employment-history" + + status, payload = await self._request(self._app(authenticator, port), path=path) + + self.assertEqual((status, payload["error_code"]), (400, "invalid_request")) + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_oversized_query_fails_before_authentication(self) -> None: + """Reject oversized query material before parsing or identity work.""" + authenticator = RecordingAuthenticator(self.principal) + port = EmptyHistoryPort() + query = DEFAULT_QUERY + b"&padding=" + (b"x" * 4096) + + status, payload = await self._request(self._app(authenticator, port), query=query) + + self.assertEqual((status, payload["error_code"]), (400, "invalid_request")) + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + +if __name__ == "__main__": + unittest.main() From 15cd1ec7680dd059fa926bad88d7a89c0598716f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:35:17 +0900 Subject: [PATCH 09/27] fix(people): harden Employment-history HTTP boundary --- .../employment_history_http.py | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/employment_history_http.py b/services/people-api/src/orgmetra_people_api/employment_history_http.py index a62d82bc7..823f45a3b 100644 --- a/services/people-api/src/orgmetra_people_api/employment_history_http.py +++ b/services/people-api/src/orgmetra_people_api/employment_history_http.py @@ -19,6 +19,7 @@ from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy from orgmetra_people_api.auth import ( + AuthenticatedPrincipal, AuthenticationFailed, TokenAuthenticator, extract_bearer_token, @@ -45,6 +46,9 @@ ) _MAX_UUID_INT = (1 << 128) - 1 _REQUIRED_QUERY_KEYS = frozenset({"known_at", "purpose", "fields"}) +_MAX_REQUEST_PATH_CHARACTERS = 256 +_MAX_QUERY_STRING_BYTES = 4096 +_MAX_QUERY_FIELDS = len(_REQUIRED_QUERY_KEYS) + 1 _SUPPORT_REFERENCE_RANDOM_BYTES = 24 @@ -95,6 +99,34 @@ async def _send_error( ) +async def _send_authentication_backend_error( + send: AsgiSend, + *, + request: _ParsedEmploymentHistoryRequest, + error: Exception, +) -> None: + """Record identity-backend failure metadata and emit one non-disclosing 500.""" + support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + _LOGGER.error( + "Employment-history authentication backend failed", + extra={ + "route": "employment_history", + "tenant_record_id": str(request.tenant_record_id), + "exception_type": type(error).__name__, + "support_reference": support_reference, + }, + ) + await _emit_json( + send, + status=500, + payload={ + "error": "internal_error", + "message": "Retry later or contact an Orgmetra operator with non-secret request metadata; never include the bearer token.", + }, + support_reference=support_reference, + ) + + @dataclass(frozen=True, slots=True) class EmploymentHistoryAsgiApp: """Expose one tenant-scoped, read-only Employment-history route. @@ -148,6 +180,14 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send message="Use /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history.", ) return + if len(path) > _MAX_REQUEST_PATH_CHARACTERS: + await _send_error( + send, + status=400, + error_code="invalid_request", + message="Use the canonical Employment-history route without oversized path data, then retry.", + ) + return try: request = _parse_employment_history_request(path, scope.get("query_string", b"")) @@ -163,6 +203,8 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send try: bearer_token = extract_bearer_token(_authorization_header(scope)) principal = await self.authenticator.authenticate(bearer_token) + if type(principal) is not AuthenticatedPrincipal: + raise TypeError("authenticator returned an invalid principal") except AuthenticationFailed: await _send_error( send, @@ -172,6 +214,9 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send extra_headers=((b"www-authenticate", b"Bearer"),), ) return + except Exception as error: # noqa: BLE001 - identity backend failures must remain client-safe. + await _send_authentication_backend_error(send, request=request, error=error) + return try: view = read_employment_history( @@ -243,9 +288,16 @@ def _parse_employment_history_request(path: str, raw_query: object) -> _ParsedEm if not isinstance(raw_query, bytes): raise _InvalidHttpRequest("query_string must be bytes") + if len(raw_query) > _MAX_QUERY_STRING_BYTES: + raise _InvalidHttpRequest("query string exceeds the accepted size") try: query_text = raw_query.decode("ascii") - pairs = parse_qsl(query_text, keep_blank_values=True, strict_parsing=True) + pairs = parse_qsl( + query_text, + keep_blank_values=True, + strict_parsing=True, + max_num_fields=_MAX_QUERY_FIELDS, + ) except (UnicodeDecodeError, ValueError) as error: raise _InvalidHttpRequest("query string is malformed") from error From 28c8aeb4998d20914eb49bb43e6a56388cfa7d82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:36:29 +0900 Subject: [PATCH 10/27] docs(adr): record Employment-history HTTP boundary hardening --- docs/adr/0155-employment-history-http-read.md | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/docs/adr/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md index e6312e538..73c002c5b 100644 --- a/docs/adr/0155-employment-history-http-read.md +++ b/docs/adr/0155-employment-history-http-read.md @@ -1,6 +1,6 @@ # ADR 0155: Expose governed Employment history through a read-only HTTP boundary -- **Status:** Proposed on active stacked PR #155; not protected-main truth until integrated +- **Status:** Proposed on active stacked PR #155; not protected-`develop` truth until integrated - **Date:** 2026-08-30 - **Owners:** Orgmetra People API / customer read boundary - **Extends:** ADR 0008 (purpose-bound PII authorization), ADR 0149 (Employment-history read) @@ -13,6 +13,13 @@ boundary that preserves the same tenant, Person, purpose, field, bitemporal, and no-disclosure controls without adding Employment mutation or employment- decision authority. +The transport also sits directly on caller-controlled HTTP input and an external +identity backend. Those boundaries must remain bounded and fail closed before +purpose authorization or persistence: an oversized request must not consume +unbounded parser work, an unexpected authenticator failure must not escape the +ASGI boundary, and an arbitrary object returned by an authenticator must not be +treated as an authenticated Orgmetra principal. + ## Decision Add `EmploymentHistoryAsgiApp` with this route: @@ -27,13 +34,22 @@ GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history 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_employment_history()`. The operation declares -`orgmetra.people.employment_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. +caps the path at 256 characters and the raw query string at 4096 bytes, and it +bounds query-field parsing to one more than the exact required-key cardinality +so malformed extra input fails before identity or persistence work. It reuses +the existing People ASGI JSON transport and bounded authorization-header parser. + +The route authenticates exactly one Bearer credential and accepts only an exact +`AuthenticatedPrincipal`. `AuthenticationFailed` maps to 401. Any other identity- +backend exception or noncanonical authenticator result is logged with an opaque +support reference and maps to the client-safe 500 envelope; the exception text +and bearer credential are not returned. Only after that identity boundary does +the route delegate to `read_employment_history()`. + +The operation declares `orgmetra.people.employment_history.read`, returns only +authorized fields, uses `Cache-Control: no-store` and `Vary: Authorization`, and +maps malformed input, authentication, authorization, integrity, and unexpected +persistence failures to the published client-safe error envelope. OpenAPI publishes the route, query/path parameters, Employment-history response, scope, and 400/401/403/409 responses. Repository-owned acceptance is executed by @@ -47,23 +63,38 @@ and retain the exact 100% statement and branch coverage requirement. - Customers receive one stable, read-only Employment-history boundary. - Existing Employment-history service ownership remains responsible for purpose-bound authorization, bitemporal validation, and persistence access. +- Caller-controlled route and query work is bounded before authentication. +- Identity-backend failures and invalid principal objects cannot become uncaught + ASGI failures or reach protected 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, export, writes, cross-service joins, or high-impact employment decisions; each requires a separate contract. - Current #149 contains later application-integrity repairs and protected #161 - workflow consolidation. This PR must adopt that parent through ordinary - non-force reconciliation before any protected-integration claim. + workflow consolidation. This PR adopted that parent through ordinary non-force + reconciliation before the current transport hardening. ## Verification -The test-only child head `6c2d6b89` fails during collection while the HTTP -adapter module is absent. The implementation retains that test-first chain. -Historical evidence from the pre-consolidation feature head does not transfer to -the current branch. After semantic parent reconciliation, the resulting exact -head must run the canonical Foundation, security, CodeQL, model-review, and -independent-review gates before integration. +The original test-only child head `6c2d6b89` failed during collection while the +HTTP adapter module was absent. That historical test-first chain is retained. + +A fresh transport-boundary review found that the Employment-history route had +not inherited four controls already present on the canonical People HTTP path: +bounded path length, bounded query bytes/field parsing, exact principal type, +and client-safe handling of unexpected identity-backend failure. Test-first +commit `2bcf5586745b57b19b65a9b9c801497a409c3b66` adds focused regressions for +those cases. Repair `15cd1ec7680dd059fa926bad88d7a89c0598716f` implements the +minimum matching boundary controls without widening authorization or persistence +ownership. + +Because #155 remains intentionally stacked on #149, the protected Foundation +pull-request trigger does not provide hosted RED or GREEN evidence for those +short-lived/current stacked heads. After the owner stack reaches protected +`develop`, the final exact head must reacquire Foundation, security, CodeQL, +model-review, and qualifying independent-review evidence. Historical and +predecessor-head results do not transfer. RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and PostgreSQL temporal/read-boundary guidance inform this transport decision. They From 8d7871bd41b98c1f09bade8c17260d8e1b297daf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:36:58 +0900 Subject: [PATCH 11/27] docs(traceability): bind Employment-history HTTP hardening evidence --- .../traceability/employment-history-http-read.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md index d16a96fa5..54c38032d 100644 --- a/docs/traceability/employment-history-http-read.md +++ b/docs/traceability/employment-history-http-read.md @@ -14,11 +14,13 @@ surface. | Requirement | Production boundary | Regression | | --- | --- | --- | | Validate before protected work | `EmploymentHistoryAsgiApp` 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 | +| Bound caller-controlled transport work | path length is capped at 256 characters; raw query at 4096 bytes; `parse_qsl` accepts at most one field beyond the exact required-key cardinality before rejecting it | boundary-hardening regressions prove oversized path/query fail before authenticator or read-port calls | +| Authenticate one bearer credential | existing bounded `_authorization_header` and `extract_bearer_token` contracts | missing, duplicate, malformed, non-ASCII, and rejected credentials return 401 | +| Preserve authenticated identity integrity | only exact `AuthenticatedPrincipal` is accepted after authentication; unexpected identity-backend failures become client-safe 500 responses with opaque support references | arbitrary principal result and secret-bearing authenticator exception both fail before persistence; response omits backend secret text | | Use least privilege and exact purpose | `orgmetra.people.employment_history.read` plus `read_employment_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 Employment-history service | call capture and service bitemporal tests | | Minimize the response | `resource_reference` plus authorized `entries[].fields` only | successful and empty-result response assertions; no Person/Position/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 | +| Fail closed without disclosure | stable 400/401/403/409/500 client-safe envelopes and opaque support reference | integrity, persistence, and identity-backend failures assert no internal details | | Publish the same customer contract | OpenAPI route, parameters, response schema, scope, and responses | Python/Node structural OpenAPI mutation tests | | Keep evidence on the exact candidate | canonical Foundation checks out the PR head and executes the complete People suite when the stack returns to a protected-`develop` PR boundary | compile, exact 100% statement/branch coverage, repository validation, and clean checkout | @@ -30,7 +32,9 @@ surface. 4. **Repository-quality repair:** protected #161 consolidated repository-owned validation into Foundation. Commit `ec2389f47f2ed6b0b10ee0a7ce5d931770a09dcf` retired the obsolete Employment-history leaf workflow instead of recreating a second owner. 5. **Parent reconciliation:** ordinary two-parent merge `32ce60e60d718f4007dc4bc0a42d370e5470b60f` adopted current #149 `93f415922592734d9d4ba3afb69a8633ecb3de15` without force-push or destructive rebase while preserving the complete HTTP/OpenAPI/security/docs/tests delta. 6. **Provenance repair:** the merge removed the obsolete Job Analysis leaf-workflow requirement inherited from the pre-#161 child validators, corrected stale leaf-workflow claims in `CHANGELOG.md`, `docs/TEST_STRATEGY.md`, and the People README, and resealed `manifest.json` from the resolved required-artifact bytes. -7. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation and predecessor-head results do not transfer. +7. **Transport RED contract:** `2bcf5586745b57b19b65a9b9c801497a409c3b66` adds focused regressions requiring unexpected identity-backend failure, arbitrary authenticator result, oversized path, and oversized query to fail closed before protected persistence. The stacked branch does not receive the protected Foundation PR trigger, so this is source-level test-first lineage rather than claimed hosted RED. +8. **Minimum causal repair:** `15cd1ec7680dd059fa926bad88d7a89c0598716f` adds the same bounded path/query contract as the canonical People route, requires exact `AuthenticatedPrincipal`, and translates unexpected identity-backend failure to a logged client-safe 500 with an opaque support reference. +9. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation and predecessor-head results do not transfer. ## Stack authority @@ -55,6 +59,12 @@ governed Person/Employment lineage. It does not join Position, Assignment, compensation, candidate, performance, credential, prompt, or model-output data. It performs no write, audit/outbox mutation, or high-impact employment decision. +Caller-controlled path/query work is bounded before identity or persistence +work. Authentication failure remains distinguishable as 401, while identity- +backend malfunction or a noncanonical principal object fails closed as a generic +500 and never reaches authorization/persistence. Bearer credentials and backend +exception text are not copied into customer responses. + ## Out of scope - Pagination or export workflows. From 8a367eb8885807dc21581c55e6a21b10e2ca5799 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 16:42:15 +0900 Subject: [PATCH 12/27] test(people): reject oversized Employment path before routing --- ...mployment_history_http_boundary_hardening.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/services/people-api/tests/test_employment_history_http_boundary_hardening.py b/services/people-api/tests/test_employment_history_http_boundary_hardening.py index 64855a6a1..90e23345a 100644 --- a/services/people-api/tests/test_employment_history_http_boundary_hardening.py +++ b/services/people-api/tests/test_employment_history_http_boundary_hardening.py @@ -5,6 +5,7 @@ import json import unittest from datetime import datetime, timezone +from unittest.mock import patch from uuid import UUID from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy @@ -134,6 +135,22 @@ async def test_noncanonical_authenticator_result_is_client_safe_and_skips_persis self.assertEqual((status, payload["error_code"]), (500, "internal_error")) self.assertEqual(port.calls, []) + async def test_oversized_path_fails_before_route_tokenization(self) -> None: + """Reject oversized route material before route decomposition or identity work.""" + authenticator = RecordingAuthenticator(self.principal) + port = EmptyHistoryPort() + path = "/v1/tenants/" + ("a" * 300) + f"/people/{PERSON}/employment-history" + + with patch( + "orgmetra_people_api.employment_history_http._looks_like_employment_history_route", + side_effect=AssertionError("route tokenizer must not receive oversized path data"), + ): + status, payload = await self._request(self._app(authenticator, port), path=path) + + self.assertEqual((status, payload["error_code"]), (400, "invalid_request")) + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + async def test_oversized_path_fails_before_authentication(self) -> None: """Reject oversized route material before invoking the identity backend.""" authenticator = RecordingAuthenticator(self.principal) From 99c3ec578a57c31f039cab70b6e3a90a4b85623a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 16:42:47 +0900 Subject: [PATCH 13/27] fix(people): bound Employment path before tokenization --- .../src/orgmetra_people_api/employment_history_http.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/employment_history_http.py b/services/people-api/src/orgmetra_people_api/employment_history_http.py index 823f45a3b..fe30cef0f 100644 --- a/services/people-api/src/orgmetra_people_api/employment_history_http.py +++ b/services/people-api/src/orgmetra_people_api/employment_history_http.py @@ -172,7 +172,7 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send return path = scope.get("path") - if not isinstance(path, str) or not _looks_like_employment_history_route(path): + if not isinstance(path, str): await _send_error( send, status=404, @@ -188,6 +188,14 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send message="Use the canonical Employment-history route without oversized path data, then retry.", ) return + if not _looks_like_employment_history_route(path): + await _send_error( + send, + status=404, + error_code="route_not_found", + message="Use /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history.", + ) + return try: request = _parse_employment_history_request(path, scope.get("query_string", b"")) From 682d55e80478963e2bab474491e822615f7bffab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 16:43:24 +0900 Subject: [PATCH 14/27] docs(adr): bind Employment path limit before routing --- docs/adr/0155-employment-history-http-read.md | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/adr/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md index 73c002c5b..33c005fa5 100644 --- a/docs/adr/0155-employment-history-http-read.md +++ b/docs/adr/0155-employment-history-http-read.md @@ -34,10 +34,12 @@ GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history 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 -caps the path at 256 characters and the raw query string at 4096 bytes, and it -bounds query-field parsing to one more than the exact required-key cardinality -so malformed extra input fails before identity or persistence work. It reuses -the existing People ASGI JSON transport and bounded authorization-header parser. +caps the path at 256 characters **before route tokenization** and the raw query +string at 4096 bytes before query parsing. `parse_qsl` is additionally bounded to +one more than the exact required-key cardinality. The ordering is part of the +security contract: an oversized path cannot reach the route helper's +`strip()`/`split()` decomposition or UUID parsing. The route reuses the existing +People ASGI JSON transport and bounded authorization-header parser. The route authenticates exactly one Bearer credential and accepts only an exact `AuthenticatedPrincipal`. `AuthenticationFailed` maps to 401. Any other identity- @@ -63,7 +65,8 @@ and retain the exact 100% statement and branch coverage requirement. - Customers receive one stable, read-only Employment-history boundary. - Existing Employment-history service ownership remains responsible for purpose-bound authorization, bitemporal validation, and persistence access. -- Caller-controlled route and query work is bounded before authentication. +- Caller-controlled route and query work is bounded before route/query parser or + authentication work. - Identity-backend failures and invalid principal objects cannot become uncaught ASGI failures or reach protected persistence. - Error support references are opaque and safe for customer correlation; the @@ -80,21 +83,28 @@ and retain the exact 100% statement and branch coverage requirement. The original test-only child head `6c2d6b89` failed during collection while the HTTP adapter module was absent. That historical test-first chain is retained. -A fresh transport-boundary review found that the Employment-history route had -not inherited four controls already present on the canonical People HTTP path: +A transport-boundary review found that the Employment-history route had not +inherited four controls already present on the canonical People HTTP path: bounded path length, bounded query bytes/field parsing, exact principal type, and client-safe handling of unexpected identity-backend failure. Test-first -commit `2bcf5586745b57b19b65a9b9c801497a409c3b66` adds focused regressions for -those cases. Repair `15cd1ec7680dd059fa926bad88d7a89c0598716f` implements the -minimum matching boundary controls without widening authorization or persistence -ownership. +commit `2bcf5586745b57b19b65a9b9c801497a409c3b66` added focused regressions and +repair `15cd1ec7680dd059fa926bad88d7a89c0598716f` implemented those boundaries. + +A later exact-order review found that the 256-character path check still occurred +after `_looks_like_employment_history_route()`, so oversized input reached +`strip()`/`split()` before rejection. Test-only head +`8a367eb8885807dc21581c55e6a21b10e2ca5799` patches that route helper to fail +if called for an oversized path; it is RED against the preceding order. Causal +repair `99c3ec578a57c31f039cab70b6e3a90a4b85623a` moves the length gate ahead of +route tokenization while retaining the existing 404 behavior for non-string and +normal-sized nonmatching routes. Because #155 remains intentionally stacked on #149, the protected Foundation -pull-request trigger does not provide hosted RED or GREEN evidence for those -short-lived/current stacked heads. After the owner stack reaches protected -`develop`, the final exact head must reacquire Foundation, security, CodeQL, -model-review, and qualifying independent-review evidence. Historical and -predecessor-head results do not transfer. +pull-request trigger does not provide hosted RED or GREEN evidence for these +stacked heads. After the owner stack reaches protected `develop`, the final exact +head must reacquire Foundation, security, SAST, CodeQL, model-review, and +qualifying independent-review evidence. Historical and predecessor-head results +do not transfer. RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and PostgreSQL temporal/read-boundary guidance inform this transport decision. They From 03030dbccba3b6044e2b4d8201203f309d4f40de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 16:43:44 +0900 Subject: [PATCH 15/27] docs(trace): currentize Employment HTTP parser bound --- .../employment-history-http-read.md | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md index 54c38032d..ae3dd146e 100644 --- a/docs/traceability/employment-history-http-read.md +++ b/docs/traceability/employment-history-http-read.md @@ -14,7 +14,7 @@ surface. | Requirement | Production boundary | Regression | | --- | --- | --- | | Validate before protected work | `EmploymentHistoryAsgiApp` parses route, query, UUIDs, UTC cutoff, purpose, and fields before authentication | malformed input cases prove no authenticator or read-port call | -| Bound caller-controlled transport work | path length is capped at 256 characters; raw query at 4096 bytes; `parse_qsl` accepts at most one field beyond the exact required-key cardinality before rejecting it | boundary-hardening regressions prove oversized path/query fail before authenticator or read-port calls | +| Bound caller-controlled transport work | path length is capped at 256 characters before route tokenization; raw query at 4096 bytes before `parse_qsl`; query field count is bounded before authentication | boundary-hardening regressions prove oversized path never reaches route tokenization and oversized query never reaches `parse_qsl`, authenticator, or read port | | Authenticate one bearer credential | existing bounded `_authorization_header` and `extract_bearer_token` contracts | missing, duplicate, malformed, non-ASCII, and rejected credentials return 401 | | Preserve authenticated identity integrity | only exact `AuthenticatedPrincipal` is accepted after authentication; unexpected identity-backend failures become client-safe 500 responses with opaque support references | arbitrary principal result and secret-bearing authenticator exception both fail before persistence; response omits backend secret text | | Use least privilege and exact purpose | `orgmetra.people.employment_history.read` plus `read_employment_history()` policy binding | disallowed fields return 403 before the port is called | @@ -30,27 +30,23 @@ surface. 2. **Expected RED:** focused collection failed with `ModuleNotFoundError` at that owning module boundary; this is distinct from a missing dependency-path invocation. 3. **Implementation:** the smallest separate ASGI adapter, package-root export, OpenAPI contract, and structural OpenAPI regressions were added. 4. **Repository-quality repair:** protected #161 consolidated repository-owned validation into Foundation. Commit `ec2389f47f2ed6b0b10ee0a7ce5d931770a09dcf` retired the obsolete Employment-history leaf workflow instead of recreating a second owner. -5. **Parent reconciliation:** ordinary two-parent merge `32ce60e60d718f4007dc4bc0a42d370e5470b60f` adopted current #149 `93f415922592734d9d4ba3afb69a8633ecb3de15` without force-push or destructive rebase while preserving the complete HTTP/OpenAPI/security/docs/tests delta. -6. **Provenance repair:** the merge removed the obsolete Job Analysis leaf-workflow requirement inherited from the pre-#161 child validators, corrected stale leaf-workflow claims in `CHANGELOG.md`, `docs/TEST_STRATEGY.md`, and the People README, and resealed `manifest.json` from the resolved required-artifact bytes. -7. **Transport RED contract:** `2bcf5586745b57b19b65a9b9c801497a409c3b66` adds focused regressions requiring unexpected identity-backend failure, arbitrary authenticator result, oversized path, and oversized query to fail closed before protected persistence. The stacked branch does not receive the protected Foundation PR trigger, so this is source-level test-first lineage rather than claimed hosted RED. -8. **Minimum causal repair:** `15cd1ec7680dd059fa926bad88d7a89c0598716f` adds the same bounded path/query contract as the canonical People route, requires exact `AuthenticatedPrincipal`, and translates unexpected identity-backend failure to a logged client-safe 500 with an opaque support reference. -9. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation and predecessor-head results do not transfer. +5. **Parent reconciliation:** ordinary two-parent merge `32ce60e60d718f4007dc4bc0a42d370e5470b60f` adopted the then-current #149 without force-push or destructive rebase while preserving the complete HTTP/OpenAPI/security/docs/tests delta. +6. **Provenance repair:** the merge removed obsolete pre-#161 leaf-workflow assumptions, corrected stale quality-owner claims, and resealed `manifest.json` from the resolved required-artifact bytes. +7. **Transport RED contract:** `2bcf5586745b57b19b65a9b9c801497a409c3b66` adds focused regressions requiring unexpected identity-backend failure, arbitrary authenticator result, oversized path, and oversized query to fail closed before protected persistence. +8. **Minimum causal repair:** `15cd1ec7680dd059fa926bad88d7a89c0598716f` adds bounded path/query handling, exact `AuthenticatedPrincipal`, and non-disclosing identity-backend failure handling. +9. **Owner-lineage restack:** ordinary two-parent merge `554e223ee5133c2a30646c1c048cc004e7f1eb36` adopts current #149 `d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750`, which already carries canonical #55's retained-UUID scalar-authority repair. +10. **Route-tokenization RED:** `8a367eb8885807dc21581c55e6a21b10e2ca5799` requires an oversized path to be rejected before `_looks_like_employment_history_route()` executes. The preceding implementation called the route tokenizer before its size gate. +11. **Route-tokenization causal repair:** `99c3ec578a57c31f039cab70b6e3a90a4b85623a` moves the 256-character gate ahead of route decomposition while retaining ordinary 404 routing semantics. +12. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/SAST/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation and predecessor-head results do not transfer. ## Stack authority Canonical Employment-history application owner #149 is -`93f415922592734d9d4ba3afb69a8633ecb3de15` on #55. The HTTP branch was -originally created from snapshot `44c83128701f1985f8566b39cbf837c7b20f0111`. -Merge `32ce60e60d718f4007dc4bc0a42d370e5470b60f` makes the current HTTP lineage a -true descendant of the live #149 owner: comparison against that parent is -0-behind and contains only the HTTP transport, OpenAPI, security, documentation, -tests, and deterministic provenance delta. The retired feature-local workflow -remains absent. - -The merge also repaired a third pre-consolidation documentation claim that was -found during reconciliation: the inherited People README still described a -standalone People API quality workflow. It now names canonical Foundation as the -repository-owned acceptance path instead of reviving a duplicate workflow. +`d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750` on #55. #155 ordinary-forward +adopted that owner in `554e223ee5133c2a30646c1c048cc004e7f1eb36`; the current HTTP lineage remains +0-behind that direct base and keeps the retired feature-local workflow absent. +The later route-tokenization RED/repair changes only #155-owned transport/tests +and their ADR/traceability evidence; no parent-owned source is copied downstream. ## Security and data boundary @@ -59,11 +55,11 @@ governed Person/Employment lineage. It does not join Position, Assignment, compensation, candidate, performance, credential, prompt, or model-output data. It performs no write, audit/outbox mutation, or high-impact employment decision. -Caller-controlled path/query work is bounded before identity or persistence -work. Authentication failure remains distinguishable as 401, while identity- -backend malfunction or a noncanonical principal object fails closed as a generic -500 and never reaches authorization/persistence. Bearer credentials and backend -exception text are not copied into customer responses. +Caller-controlled path/query work is bounded before route/query parsing, identity, +or persistence work. Authentication failure remains distinguishable as 401, +while identity-backend malfunction or a noncanonical principal object fails closed +as a generic 500 and never reaches authorization/persistence. Bearer credentials +and backend exception text are not copied into customer responses. ## Out of scope From d998cd684adee518b04ddc37cfad7c17ea151d8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:06:28 +0900 Subject: [PATCH 16/27] test(people): expose Employment HTTP backend and blocking regressions --- ...loyment_history_http_boundary_hardening.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_employment_history_http_boundary_hardening.py b/services/people-api/tests/test_employment_history_http_boundary_hardening.py index 90e23345a..d21720f19 100644 --- a/services/people-api/tests/test_employment_history_http_boundary_hardening.py +++ b/services/people-api/tests/test_employment_history_http_boundary_hardening.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import re +import threading import unittest from datetime import datetime, timezone from unittest.mock import patch @@ -18,6 +20,7 @@ b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&" b"fields=effective_from,employment_status_code" ) +_SUPPORT_REFERENCE = re.compile(r"^err_[A-Za-z0-9_-]{20,80}$") class RecordingAuthenticator: @@ -41,6 +44,7 @@ class EmptyHistoryPort: def __init__(self) -> None: self.calls: list[tuple[UUID, UUID, datetime]] = [] + self.thread_ids: list[int] = [] def read_employment_history( self, @@ -51,6 +55,7 @@ def read_employment_history( ) -> tuple[EmploymentHistoryRecord, ...]: """Return an immutable empty history for transport-boundary tests.""" self.calls.append((tenant_record_id, person_record_id, known_at)) + self.thread_ids.append(threading.get_ident()) return () @@ -111,7 +116,7 @@ async def send(message: dict[str, object]) -> None: return int(start["status"]), json.loads(bytes(body["body"])) async def test_authentication_backend_failure_is_client_safe_and_skips_persistence(self) -> None: - """An identity-backend exception must not escape the ASGI boundary or leak secrets.""" + """An identity-backend exception must return the published envelope without secrets.""" authenticator = RecordingAuthenticator( self.principal, error=RuntimeError("oidc client_secret=do-not-leak"), @@ -121,10 +126,27 @@ async def test_authentication_backend_failure_is_client_safe_and_skips_persisten status, payload = await self._request(self._app(authenticator, port)) self.assertEqual((status, payload["error_code"]), (500, "internal_error")) + self.assertEqual(payload["error"], "internal_error") + self.assertEqual(payload["next_action"], payload["message"]) + self.assertRegex(str(payload["support_reference"]), _SUPPORT_REFERENCE) self.assertNotIn("client_secret", json.dumps(payload)) self.assertEqual(authenticator.tokens, ["opaque-token"]) self.assertEqual(port.calls, []) + async def test_synchronous_service_read_runs_off_event_loop_thread(self) -> None: + """Do not run synchronous Employment/PostgreSQL work on the ASGI event loop.""" + event_loop_thread_id = threading.get_ident() + authenticator = RecordingAuthenticator(self.principal) + port = EmptyHistoryPort() + + status, payload = await self._request(self._app(authenticator, port)) + + self.assertEqual(status, 200) + self.assertEqual(payload["entries"], []) + self.assertEqual(len(port.calls), 1) + self.assertEqual(len(port.thread_ids), 1) + self.assertNotEqual(port.thread_ids[0], event_loop_thread_id) + async def test_noncanonical_authenticator_result_is_client_safe_and_skips_persistence(self) -> None: """A structurally arbitrary principal cannot cross the authenticated identity boundary.""" authenticator = RecordingAuthenticator(object()) From 3432b08b67f28cf5e665346494104ec15d523590 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:06:59 +0900 Subject: [PATCH 17/27] fix(people): keep Employment HTTP failures valid and DB reads off loop --- .../orgmetra_people_api/employment_history_http.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/employment_history_http.py b/services/people-api/src/orgmetra_people_api/employment_history_http.py index fe30cef0f..16561360e 100644 --- a/services/people-api/src/orgmetra_people_api/employment_history_http.py +++ b/services/people-api/src/orgmetra_people_api/employment_history_http.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import datetime import logging @@ -107,6 +108,10 @@ async def _send_authentication_backend_error( ) -> None: """Record identity-backend failure metadata and emit one non-disclosing 500.""" support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + client_message = ( + "Retry later or contact an Orgmetra operator with non-secret request metadata; " + "never include the bearer token." + ) _LOGGER.error( "Employment-history authentication backend failed", extra={ @@ -121,9 +126,11 @@ async def _send_authentication_backend_error( status=500, payload={ "error": "internal_error", - "message": "Retry later or contact an Orgmetra operator with non-secret request metadata; never include the bearer token.", + "error_code": "internal_error", + "message": client_message, + "next_action": client_message, + "support_reference": support_reference, }, - support_reference=support_reference, ) @@ -227,7 +234,8 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send return try: - view = read_employment_history( + view = await asyncio.to_thread( + read_employment_history, principal=principal, tenant_record_id=request.tenant_record_id, person_record_id=request.person_record_id, From eb1bd5eb3a42a0b605f5714c2c91b20f41d19665 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:07:26 +0900 Subject: [PATCH 18/27] docs(adr): record Employment HTTP backend and event-loop repairs --- docs/adr/0155-employment-history-http-read.md | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/adr/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md index 33c005fa5..a85a01450 100644 --- a/docs/adr/0155-employment-history-http-read.md +++ b/docs/adr/0155-employment-history-http-read.md @@ -18,7 +18,9 @@ identity backend. Those boundaries must remain bounded and fail closed before purpose authorization or persistence: an oversized request must not consume unbounded parser work, an unexpected authenticator failure must not escape the ASGI boundary, and an arbitrary object returned by an authenticator must not be -treated as an authenticated Orgmetra principal. +treated as an authenticated Orgmetra principal. The route also delegates to a +synchronous Employment-history service/PostgreSQL adapter, so it must not execute +blocking connection/cursor/fetch work on the ASGI event-loop thread. ## Decision @@ -43,10 +45,19 @@ People ASGI JSON transport and bounded authorization-header parser. The route authenticates exactly one Bearer credential and accepts only an exact `AuthenticatedPrincipal`. `AuthenticationFailed` maps to 401. Any other identity- -backend exception or noncanonical authenticator result is logged with an opaque -support reference and maps to the client-safe 500 envelope; the exception text -and bearer credential are not returned. Only after that identity boundary does -the route delegate to `read_employment_history()`. +backend exception or noncanonical authenticator result is logged with one opaque +support reference and maps to the published client-safe 500 envelope. The same +reference is returned in the payload together with required `error_code`, +`message`, and `next_action`; no unsupported argument is passed to the shared JSON +emitter, and exception text/bearer credentials are not returned. + +Only after that identity boundary does the route delegate to +`read_employment_history()`. That application contract remains synchronous, so the +ASGI adapter runs the complete service call with `asyncio.to_thread(...)`. +Authorization, persistence, bitemporal validation, and exception types are +unchanged; worker exceptions propagate through the await and retain the existing +403/409/500 mapping. The offload is an event-loop isolation control, not evidence +that the buyer path meets the p95 performance target. The operation declares `orgmetra.people.employment_history.read`, returns only authorized fields, uses `Cache-Control: no-store` and `Vary: Authorization`, and @@ -54,8 +65,8 @@ maps malformed input, authentication, authorization, integrity, and unexpected persistence failures to the published client-safe error envelope. OpenAPI publishes the route, query/path parameters, Employment-history response, -scope, and 400/401/403/409 responses. Repository-owned acceptance is executed by -the canonical Foundation CI after this stacked lane returns to a protected- +scope, and 400/401/403/409/500 responses. Repository-owned acceptance is executed +by canonical Foundation CI after this stacked lane returns to a protected- `develop` pull-request boundary. The historical feature-local Employment-history workflow is retired; its service tests remain part of the complete People suite and retain the exact 100% statement and branch coverage requirement. @@ -68,9 +79,10 @@ and retain the exact 100% statement and branch coverage requirement. - Caller-controlled route and query work is bounded before route/query parser or authentication work. - Identity-backend failures and invalid principal objects cannot become uncaught - ASGI failures or reach protected persistence. -- Error support references are opaque and safe for customer correlation; the - route does not expose backend exception details. + ASGI failures or reach protected persistence, and backend errors remain valid + published `ErrorResponse` documents. +- Synchronous Employment-history/PostgreSQL work no longer blocks the ASGI event + loop; exact-candidate k6/E2E latency measurement remains required separately. - The route intentionally does not add pagination, export, writes, cross-service joins, or high-impact employment decisions; each requires a separate contract. @@ -99,12 +111,22 @@ repair `99c3ec578a57c31f039cab70b6e3a90a4b85623a` moves the length gate ahead of route tokenization while retaining the existing 404 behavior for non-string and normal-sized nonmatching routes. +Sibling #154 review then exposed the same backend-envelope and event-loop defects +in this lane. Test-only `d998cd684adee518b04ddc37cfad7c17ea151d8c` +strengthens the backend-error envelope contract and requires the protected read +port to execute off the event-loop thread. The predecessor is RED against both: +`_send_json` does not accept the supplied `support_reference` keyword and +`read_employment_history()` executes directly inside the ASGI coroutine. Causal +repair `3432b08b67f28cf5e665346494104ec15d523590` places the opaque reference in +the required payload and executes the synchronous service through +`asyncio.to_thread(...)`. + Because #155 remains intentionally stacked on #149, the protected Foundation -pull-request trigger does not provide hosted RED or GREEN evidence for these -stacked heads. After the owner stack reaches protected `develop`, the final exact -head must reacquire Foundation, security, SAST, CodeQL, model-review, and -qualifying independent-review evidence. Historical and predecessor-head results -do not transfer. +pull-request trigger provides no hosted RED or GREEN for these stacked exact +heads. After the owner stack reaches protected `develop`, the final exact head +must reacquire Foundation, security, SAST, CodeQL, model-review, qualifying +independent-review evidence, and applicable buyer-path latency evidence. +Historical and predecessor-head results do not transfer. RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and PostgreSQL temporal/read-boundary guidance inform this transport decision. They From ef27e2e91e0e73c98f396f13ca8da913484c2927 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:07:48 +0900 Subject: [PATCH 19/27] docs(trace): bind Employment HTTP review repairs to executable contracts --- .../employment-history-http-read.md | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md index ae3dd146e..3d63719a0 100644 --- a/docs/traceability/employment-history-http-read.md +++ b/docs/traceability/employment-history-http-read.md @@ -16,7 +16,9 @@ surface. | Validate before protected work | `EmploymentHistoryAsgiApp` parses route, query, UUIDs, UTC cutoff, purpose, and fields before authentication | malformed input cases prove no authenticator or read-port call | | Bound caller-controlled transport work | path length is capped at 256 characters before route tokenization; raw query at 4096 bytes before `parse_qsl`; query field count is bounded before authentication | boundary-hardening regressions prove oversized path never reaches route tokenization and oversized query never reaches `parse_qsl`, authenticator, or read port | | Authenticate one bearer credential | existing bounded `_authorization_header` and `extract_bearer_token` contracts | missing, duplicate, malformed, non-ASCII, and rejected credentials return 401 | -| Preserve authenticated identity integrity | only exact `AuthenticatedPrincipal` is accepted after authentication; unexpected identity-backend failures become client-safe 500 responses with opaque support references | arbitrary principal result and secret-bearing authenticator exception both fail before persistence; response omits backend secret text | +| Preserve authenticated identity integrity | only exact `AuthenticatedPrincipal` is accepted after authentication; unexpected identity-backend failures become client-safe 500 responses with one opaque support reference | arbitrary principal result and secret-bearing authenticator exception both fail before persistence; response omits backend secret text | +| Preserve the published backend-error contract | identity-backend failure returns the same opaque reference recorded by the server inside a complete `ErrorResponse`; shared JSON emitter receives only supported arguments | focused regression requires `error`, `error_code`, `message`, `next_action`, and `support_reference` | +| Keep synchronous persistence off the ASGI event loop | synchronous `read_employment_history()` service/PostgreSQL work executes through `asyncio.to_thread(...)` | focused regression records the read-port thread and requires it to differ from the event-loop thread | | Use least privilege and exact purpose | `orgmetra.people.employment_history.read` plus `read_employment_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 Employment-history service | call capture and service bitemporal tests | | Minimize the response | `resource_reference` plus authorized `entries[].fields` only | successful and empty-result response assertions; no Person/Position/Assignment joins | @@ -37,7 +39,9 @@ surface. 9. **Owner-lineage restack:** ordinary two-parent merge `554e223ee5133c2a30646c1c048cc004e7f1eb36` adopts current #149 `d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750`, which already carries canonical #55's retained-UUID scalar-authority repair. 10. **Route-tokenization RED:** `8a367eb8885807dc21581c55e6a21b10e2ca5799` requires an oversized path to be rejected before `_looks_like_employment_history_route()` executes. The preceding implementation called the route tokenizer before its size gate. 11. **Route-tokenization causal repair:** `99c3ec578a57c31f039cab70b6e3a90a4b85623a` moves the 256-character gate ahead of route decomposition while retaining ordinary 404 routing semantics. -12. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/SAST/CodeQL/model-review and qualifying independent-review evidence. Pre-consolidation and predecessor-head results do not transfer. +12. **Backend-envelope/event-loop RED:** `d998cd684adee518b04ddc37cfad7c17ea151d8c` requires a schema-valid backend-error response with the same opaque support reference and requires synchronous protected reads to execute off the ASGI event-loop thread. The predecessor violates both contracts. +13. **Causal repair:** `3432b08b67f28cf5e665346494104ec15d523590` removes the unsupported JSON-emitter argument, returns `error_code`/`next_action`/`support_reference`, and awaits `asyncio.to_thread(read_employment_history, ...)`. +14. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/SAST/CodeQL/model-review, qualifying independent-review evidence, and applicable latency evidence. Pre-consolidation and predecessor-head results do not transfer. ## Stack authority @@ -45,10 +49,10 @@ Canonical Employment-history application owner #149 is `d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750` on #55. #155 ordinary-forward adopted that owner in `554e223ee5133c2a30646c1c048cc004e7f1eb36`; the current HTTP lineage remains 0-behind that direct base and keeps the retired feature-local workflow absent. -The later route-tokenization RED/repair changes only #155-owned transport/tests -and their ADR/traceability evidence; no parent-owned source is copied downstream. +The later transport repairs change only #155-owned HTTP source/tests and their +ADR/traceability evidence; no parent-owned source is copied downstream. -## Security and data boundary +## Security, availability, and data boundary The route reads only authorized Employment-version fields and the already- governed Person/Employment lineage. It does not join Position, Assignment, @@ -59,7 +63,13 @@ Caller-controlled path/query work is bounded before route/query parsing, identit or persistence work. Authentication failure remains distinguishable as 401, while identity-backend malfunction or a noncanonical principal object fails closed as a generic 500 and never reaches authorization/persistence. Bearer credentials -and backend exception text are not copied into customer responses. +and backend exception text are not copied into customer responses. Synchronous +Employment/PostgreSQL work is worker-thread isolated from the ASGI event loop, +without changing the short read-only transaction owned by the persistence lane. + +The worker-thread repair is not a p95 claim. Production-equivalent connection/pool +settings still require exact-candidate k6/E2E measurement before <=20 ms can be +claimed for this buyer path. ## Out of scope From a38c0b2286aff93ec19a766bcd63927754648e7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:11:32 +0900 Subject: [PATCH 20/27] test(people): preserve Employment backend support correlation --- .../test_employment_history_http_boundary_hardening.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_employment_history_http_boundary_hardening.py b/services/people-api/tests/test_employment_history_http_boundary_hardening.py index d21720f19..b8705524e 100644 --- a/services/people-api/tests/test_employment_history_http_boundary_hardening.py +++ b/services/people-api/tests/test_employment_history_http_boundary_hardening.py @@ -116,19 +116,21 @@ async def send(message: dict[str, object]) -> None: return int(start["status"]), json.loads(bytes(body["body"])) async def test_authentication_backend_failure_is_client_safe_and_skips_persistence(self) -> None: - """An identity-backend exception must return the published envelope without secrets.""" + """An identity-backend exception must retain one support reference end to end.""" authenticator = RecordingAuthenticator( self.principal, error=RuntimeError("oidc client_secret=do-not-leak"), ) port = EmptyHistoryPort() - status, payload = await self._request(self._app(authenticator, port)) + with self.assertLogs("orgmetra_people_api.employment_history_http", level="ERROR") as logs: + status, payload = await self._request(self._app(authenticator, port)) self.assertEqual((status, payload["error_code"]), (500, "internal_error")) self.assertEqual(payload["error"], "internal_error") self.assertEqual(payload["next_action"], payload["message"]) self.assertRegex(str(payload["support_reference"]), _SUPPORT_REFERENCE) + self.assertEqual(payload["support_reference"], logs.records[0].support_reference) self.assertNotIn("client_secret", json.dumps(payload)) self.assertEqual(authenticator.tokens, ["opaque-token"]) self.assertEqual(port.calls, []) From 15220135234107de82c481816f19749198c61012 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:12:13 +0900 Subject: [PATCH 21/27] fix(people): preserve canonical Employment error correlation --- .../orgmetra_people_api/employment_history_http.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/employment_history_http.py b/services/people-api/src/orgmetra_people_api/employment_history_http.py index 16561360e..1ff7f77c8 100644 --- a/services/people-api/src/orgmetra_people_api/employment_history_http.py +++ b/services/people-api/src/orgmetra_people_api/employment_history_http.py @@ -97,6 +97,7 @@ async def _send_error( "support_reference": support_reference, }, extra_headers=extra_headers, + support_reference=support_reference, ) @@ -108,10 +109,6 @@ async def _send_authentication_backend_error( ) -> None: """Record identity-backend failure metadata and emit one non-disclosing 500.""" support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" - client_message = ( - "Retry later or contact an Orgmetra operator with non-secret request metadata; " - "never include the bearer token." - ) _LOGGER.error( "Employment-history authentication backend failed", extra={ @@ -126,11 +123,9 @@ async def _send_authentication_backend_error( status=500, payload={ "error": "internal_error", - "error_code": "internal_error", - "message": client_message, - "next_action": client_message, - "support_reference": support_reference, + "message": "Retry later or contact an Orgmetra operator with non-secret request metadata; never include the bearer token.", }, + support_reference=support_reference, ) From 06390b0d5bc14fdd620ba6f83d3a7973bf0ed9a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:12:54 +0900 Subject: [PATCH 22/27] docs(adr): correct Employment HTTP shared-emitter authority --- docs/adr/0155-employment-history-http-read.md | 149 +++++++----------- 1 file changed, 59 insertions(+), 90 deletions(-) diff --git a/docs/adr/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md index a85a01450..a076c5949 100644 --- a/docs/adr/0155-employment-history-http-read.md +++ b/docs/adr/0155-employment-history-http-read.md @@ -15,12 +15,9 @@ decision authority. The transport also sits directly on caller-controlled HTTP input and an external identity backend. Those boundaries must remain bounded and fail closed before -purpose authorization or persistence: an oversized request must not consume -unbounded parser work, an unexpected authenticator failure must not escape the -ASGI boundary, and an arbitrary object returned by an authenticator must not be -treated as an authenticated Orgmetra principal. The route also delegates to a -synchronous Employment-history service/PostgreSQL adapter, so it must not execute -blocking connection/cursor/fetch work on the ASGI event-loop thread. +purpose authorization or persistence. The route delegates to a synchronous +Employment-history service/PostgreSQL adapter, so blocking connection/cursor/fetch +work must not execute on the ASGI event-loop thread. ## Decision @@ -33,101 +30,73 @@ GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history &fields=effective_from,employment_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 -caps the path at 256 characters **before route tokenization** and the raw query -string at 4096 bytes before query parsing. `parse_qsl` is additionally bounded to -one more than the exact required-key cardinality. The ordering is part of the -security contract: an oversized path cannot reach the route helper's -`strip()`/`split()` decomposition or UUID parsing. The route reuses the existing -People ASGI JSON transport and bounded authorization-header parser. - -The route authenticates exactly one Bearer credential and accepts only an exact -`AuthenticatedPrincipal`. `AuthenticationFailed` maps to 401. Any other identity- -backend exception or noncanonical authenticator result is logged with one opaque -support reference and maps to the published client-safe 500 envelope. The same -reference is returned in the payload together with required `error_code`, -`message`, and `next_action`; no unsupported argument is passed to the shared JSON -emitter, and exception text/bearer credentials are not returned. - -Only after that identity boundary does the route delegate to -`read_employment_history()`. That application contract remains synchronous, so the -ASGI adapter runs the complete service call with `asyncio.to_thread(...)`. -Authorization, persistence, bitemporal validation, and exception types are -unchanged; worker exceptions propagate through the await and retain the existing -403/409/500 mapping. The offload is an event-loop isolation control, not evidence -that the buyer path meets the p95 performance target. +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/fields, +and duplicate-field/parameter rejection before authentication. It caps the path +at 256 characters before route tokenization and the raw query at 4096 bytes before +`parse_qsl`; parser field count is bounded too. + +Authentication accepts only exact `AuthenticatedPrincipal`. The route deliberately +reuses the canonical People `_send_json` contract inherited through #149/#55. +That emitter accepts a pre-generated `support_reference`, fills `error_code` and +`next_action`, and preserves the same opaque reference in the customer response. +The Employment route therefore must pass its pre-logged reference into the shared +emitter rather than duplicate the emitter's enrichment logic. + +`read_employment_history()` remains synchronous. The ASGI adapter runs the whole +service call with `asyncio.to_thread(...)`; authorization, persistence, bitemporal +validation, and exception types remain unchanged, while synchronous DB work no +longer monopolizes the event-loop thread. This is event-loop isolation, not p95 +performance acceptance. The operation declares `orgmetra.people.employment_history.read`, returns only authorized fields, uses `Cache-Control: no-store` and `Vary: Authorization`, and maps malformed input, authentication, authorization, integrity, and unexpected persistence failures to the published client-safe error envelope. -OpenAPI publishes the route, query/path parameters, Employment-history response, -scope, and 400/401/403/409/500 responses. Repository-owned acceptance is executed -by canonical Foundation CI after this stacked lane returns to a protected- -`develop` pull-request boundary. The historical feature-local Employment-history -workflow is retired; its service tests remain part of the complete People suite -and retain the exact 100% statement and branch coverage requirement. +Repository acceptance remains owned by consolidated Foundation CI after this stack +returns to a protected-`develop` pull-request boundary. The retired feature-local +workflow is not recreated. ## Consequences -- Customers receive one stable, read-only Employment-history boundary. -- Existing Employment-history service ownership remains responsible for - purpose-bound authorization, bitemporal validation, and persistence access. -- Caller-controlled route and query work is bounded before route/query parser or - authentication work. -- Identity-backend failures and invalid principal objects cannot become uncaught - ASGI failures or reach protected persistence, and backend errors remain valid - published `ErrorResponse` documents. -- Synchronous Employment-history/PostgreSQL work no longer blocks the ASGI event - loop; exact-candidate k6/E2E latency measurement remains required separately. -- The route intentionally does not add pagination, export, writes, - cross-service joins, or high-impact employment decisions; each requires a - separate contract. -- Current #149 contains later application-integrity repairs and protected #161 - workflow consolidation. This PR adopted that parent through ordinary non-force - reconciliation before the current transport hardening. +- Customer Employment history remains a read-only purpose-bound boundary. +- Parent service/PostgreSQL owners retain authorization and persistence truth. +- Caller-controlled parsing is bounded before protected work. +- One support reference correlates route logging and the response through the + canonical shared emitter. +- Synchronous Employment/PostgreSQL work is worker-thread isolated from ASGI; + exact-candidate k6/E2E latency evidence remains separate. +- No pagination, export, mutation, cross-service join, or high-impact decision is + introduced. ## Verification -The original test-only child head `6c2d6b89` failed during collection while the -HTTP adapter module was absent. That historical test-first chain is retained. - -A transport-boundary review found that the Employment-history route had not -inherited four controls already present on the canonical People HTTP path: -bounded path length, bounded query bytes/field parsing, exact principal type, -and client-safe handling of unexpected identity-backend failure. Test-first -commit `2bcf5586745b57b19b65a9b9c801497a409c3b66` added focused regressions and -repair `15cd1ec7680dd059fa926bad88d7a89c0598716f` implemented those boundaries. - -A later exact-order review found that the 256-character path check still occurred -after `_looks_like_employment_history_route()`, so oversized input reached -`strip()`/`split()` before rejection. Test-only head -`8a367eb8885807dc21581c55e6a21b10e2ca5799` patches that route helper to fail -if called for an oversized path; it is RED against the preceding order. Causal -repair `99c3ec578a57c31f039cab70b6e3a90a4b85623a` moves the length gate ahead of -route tokenization while retaining the existing 404 behavior for non-string and -normal-sized nonmatching routes. - -Sibling #154 review then exposed the same backend-envelope and event-loop defects -in this lane. Test-only `d998cd684adee518b04ddc37cfad7c17ea151d8c` -strengthens the backend-error envelope contract and requires the protected read -port to execute off the event-loop thread. The predecessor is RED against both: -`_send_json` does not accept the supplied `support_reference` keyword and -`read_employment_history()` executes directly inside the ASGI coroutine. Causal -repair `3432b08b67f28cf5e665346494104ec15d523590` places the opaque reference in -the required payload and executes the synchronous service through -`asyncio.to_thread(...)`. +Historical test-first and transport-hardening lineage remains unchanged through +route-tokenization repair `99c3ec578a57c31f039cab70b6e3a90a4b85623a`. -Because #155 remains intentionally stacked on #149, the protected Foundation -pull-request trigger provides no hosted RED or GREEN for these stacked exact -heads. After the owner stack reaches protected `develop`, the final exact head -must reacquire Foundation, security, SAST, CodeQL, model-review, qualifying -independent-review evidence, and applicable buyer-path latency evidence. -Historical and predecessor-head results do not transfer. +Sibling #154 review identified event-loop blocking in the analogous Position route. +That availability finding is also valid here. Test-only +`d998cd684adee518b04ddc37cfad7c17ea151d8c` adds a regression requiring the +protected Employment read to execute off the event-loop thread; predecessor +`03030dbccba3b6044e2b4d8201203f309d4f40de` is RED for that contract. Causal +repair `3432b08b67f28cf5e665346494104ec15d523590` introduces +`asyncio.to_thread(...)`. -RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and -PostgreSQL temporal/read-boundary guidance inform this transport decision. They -are defense-in-depth references, not certification or merge evidence. +The sibling #154 `support_reference` signature finding does **not** transfer to +#155: this stack already inherits #55's `_send_json(..., support_reference=...)`. +The initial sibling-style repair in `3432b08...` duplicated response enrichment and +omitted the canonical `support_reference` argument, causing the shared emitter to +generate a second reference. Test-first `a38c0b2286aff93ec19a766bcd63927754648e7b` +requires the Employment route's ERROR log reference to equal the response +`support_reference`. Causal repair `15220135234107de82c481816f19749198c61012` +restores canonical emitter ownership and also passes the already-generated +reference through `_send_error`, preventing double-reference correlation drift. + +No hosted RED/GREEN is claimed for these stacked heads because #155 targets #149, +not protected `develop`. Final acceptance must be reacquired after the owner stack +returns to the protected lane, including Foundation, security, SAST, CodeQL, +model-backed review, qualifying independent review, and applicable latency evidence. + +RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and PostgreSQL +read-boundary guidance remain defense-in-depth references, not merge evidence. From 2f63f84edb17db5435907e095abf284addcdd2a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 17:13:20 +0900 Subject: [PATCH 23/27] docs(trace): correct Employment shared-emitter evidence --- .../employment-history-http-read.md | 79 ++++++------------- 1 file changed, 24 insertions(+), 55 deletions(-) diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md index 3d63719a0..61b47128f 100644 --- a/docs/traceability/employment-history-http-read.md +++ b/docs/traceability/employment-history-http-read.md @@ -4,77 +4,46 @@ ## Buyer problem -PR #149 defines an authorized Employment-history read, but a customer still -needs one stable HTTP boundary to request that history without deployment- -specific parsing, authentication, or serialization code widening the data -surface. +PR #149 defines an authorized Employment-history read, but a customer still needs one stable HTTP boundary without deployment-specific parsing, authentication, or serialization code widening the data surface. ## Requirement-to-evidence matrix | Requirement | Production boundary | Regression | | --- | --- | --- | | Validate before protected work | `EmploymentHistoryAsgiApp` parses route, query, UUIDs, UTC cutoff, purpose, and fields before authentication | malformed input cases prove no authenticator or read-port call | -| Bound caller-controlled transport work | path length is capped at 256 characters before route tokenization; raw query at 4096 bytes before `parse_qsl`; query field count is bounded before authentication | boundary-hardening regressions prove oversized path never reaches route tokenization and oversized query never reaches `parse_qsl`, authenticator, or read port | -| Authenticate one bearer credential | existing bounded `_authorization_header` and `extract_bearer_token` contracts | missing, duplicate, malformed, non-ASCII, and rejected credentials return 401 | -| Preserve authenticated identity integrity | only exact `AuthenticatedPrincipal` is accepted after authentication; unexpected identity-backend failures become client-safe 500 responses with one opaque support reference | arbitrary principal result and secret-bearing authenticator exception both fail before persistence; response omits backend secret text | -| Preserve the published backend-error contract | identity-backend failure returns the same opaque reference recorded by the server inside a complete `ErrorResponse`; shared JSON emitter receives only supported arguments | focused regression requires `error`, `error_code`, `message`, `next_action`, and `support_reference` | -| Keep synchronous persistence off the ASGI event loop | synchronous `read_employment_history()` service/PostgreSQL work executes through `asyncio.to_thread(...)` | focused regression records the read-port thread and requires it to differ from the event-loop thread | -| Use least privilege and exact purpose | `orgmetra.people.employment_history.read` plus `read_employment_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 Employment-history service | call capture and service bitemporal tests | -| Minimize the response | `resource_reference` plus authorized `entries[].fields` only | successful and empty-result response assertions; no Person/Position/Assignment joins | -| Fail closed without disclosure | stable 400/401/403/409/500 client-safe envelopes and opaque support reference | integrity, persistence, and identity-backend failures assert no internal details | -| Publish the same customer contract | OpenAPI route, parameters, response schema, scope, and responses | Python/Node structural OpenAPI mutation tests | -| Keep evidence on the exact candidate | canonical Foundation checks out the PR head and executes the complete People suite when the stack returns to a protected-`develop` PR boundary | compile, exact 100% statement/branch coverage, repository validation, and clean checkout | +| Bound caller-controlled transport work | path length is capped before route tokenization; query bytes and parser field count are bounded before authentication | oversized path/query regressions prove parser/identity/persistence are not reached | +| Preserve authenticated identity integrity | only exact `AuthenticatedPrincipal` is accepted; backend malfunction becomes opaque 500 | arbitrary principal and secret-bearing authenticator failures stop before persistence | +| Preserve one support reference | route generates/logs one opaque reference and passes it into canonical #55 `_send_json`, which enriches the published `ErrorResponse` without changing the reference | `a38c0b...` compares the Employment route ERROR-log `support_reference` with the response value | +| Keep synchronous persistence off the ASGI event loop | synchronous `read_employment_history()` service/PostgreSQL work executes through `asyncio.to_thread(...)` | `d998cd...` records the read-port thread and requires it to differ from the event-loop thread | +| Use least privilege and exact purpose | `orgmetra.people.employment_history.read` plus `read_employment_history()` policy binding | disallowed fields return 403 before port call | +| Preserve bitemporal scope | exact UTC `known_at` passed to service | service and PostgreSQL cutoff contracts | +| Minimize response | `resource_reference` plus authorized `entries[].fields` only | success/empty-response tests; no Position/Assignment joins | +| Keep evidence on exact candidate | consolidated Foundation owns repository acceptance after protected-base retarget | predecessor and feature-local verdicts do not transfer | ## Test-first and reconciliation chain -1. **Contract-only child head:** `6c2d6b89` added HTTP regressions while `orgmetra_people_api.employment_history_http` was absent. -2. **Expected RED:** focused collection failed with `ModuleNotFoundError` at that owning module boundary; this is distinct from a missing dependency-path invocation. -3. **Implementation:** the smallest separate ASGI adapter, package-root export, OpenAPI contract, and structural OpenAPI regressions were added. -4. **Repository-quality repair:** protected #161 consolidated repository-owned validation into Foundation. Commit `ec2389f47f2ed6b0b10ee0a7ce5d931770a09dcf` retired the obsolete Employment-history leaf workflow instead of recreating a second owner. -5. **Parent reconciliation:** ordinary two-parent merge `32ce60e60d718f4007dc4bc0a42d370e5470b60f` adopted the then-current #149 without force-push or destructive rebase while preserving the complete HTTP/OpenAPI/security/docs/tests delta. -6. **Provenance repair:** the merge removed obsolete pre-#161 leaf-workflow assumptions, corrected stale quality-owner claims, and resealed `manifest.json` from the resolved required-artifact bytes. -7. **Transport RED contract:** `2bcf5586745b57b19b65a9b9c801497a409c3b66` adds focused regressions requiring unexpected identity-backend failure, arbitrary authenticator result, oversized path, and oversized query to fail closed before protected persistence. -8. **Minimum causal repair:** `15cd1ec7680dd059fa926bad88d7a89c0598716f` adds bounded path/query handling, exact `AuthenticatedPrincipal`, and non-disclosing identity-backend failure handling. -9. **Owner-lineage restack:** ordinary two-parent merge `554e223ee5133c2a30646c1c048cc004e7f1eb36` adopts current #149 `d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750`, which already carries canonical #55's retained-UUID scalar-authority repair. -10. **Route-tokenization RED:** `8a367eb8885807dc21581c55e6a21b10e2ca5799` requires an oversized path to be rejected before `_looks_like_employment_history_route()` executes. The preceding implementation called the route tokenizer before its size gate. -11. **Route-tokenization causal repair:** `99c3ec578a57c31f039cab70b6e3a90a4b85623a` moves the 256-character gate ahead of route decomposition while retaining ordinary 404 routing semantics. -12. **Backend-envelope/event-loop RED:** `d998cd684adee518b04ddc37cfad7c17ea151d8c` requires a schema-valid backend-error response with the same opaque support reference and requires synchronous protected reads to execute off the ASGI event-loop thread. The predecessor violates both contracts. -13. **Causal repair:** `3432b08b67f28cf5e665346494104ec15d523590` removes the unsupported JSON-emitter argument, returns `error_code`/`next_action`/`support_reference`, and awaits `asyncio.to_thread(read_employment_history, ...)`. -14. **Required verification:** after the owner stack returns to the canonical protected-`develop` PR boundary, reacquire exact-head Foundation/security/SAST/CodeQL/model-review, qualifying independent-review evidence, and applicable latency evidence. Pre-consolidation and predecessor-head results do not transfer. +1. Historical contract-only `6c2d6b89` established the absent-module RED; later implementation added the ASGI/OpenAPI boundary. +2. `2bcf5586745b57b19b65a9b9c801497a409c3b66` added transport-boundary regressions; `15cd1ec7680dd059fa926bad88d7a89c0598716f` added bounded input, exact principal, and client-safe backend handling. +3. Ordinary parent reconciliation adopted #149/#55 truth and retired the obsolete feature-local quality owner. +4. `8a367eb8885807dc21581c55e6a21b10e2ca5799` proved oversized paths still reached route tokenization; `99c3ec578a57c31f039cab70b6e3a90a4b85623a` moved the length gate ahead of `strip()`/`split()`. +5. Sibling #154 review exposed a synchronous-service-on-ASGI-loop risk. This finding independently reproduces in #155: predecessor `03030dbccba3b6044e2b4d8201203f309d4f40de` directly calls `read_employment_history()`. Test-only `d998cd684adee518b04ddc37cfad7c17ea151d8c` requires the protected read to run off the event-loop thread; `3432b08b67f28cf5e665346494104ec15d523590` introduces `asyncio.to_thread(...)`. +6. The sibling #154 `support_reference` signature finding does **not** reproduce in #155. #155 already inherits canonical #55 `_send_json(..., support_reference=...)`, so the predecessor backend path already returns a schema-enriched response through the shared emitter. The initial sibling-style source edit in `3432b08...` incorrectly omitted that argument, causing the shared emitter to mint a second reference. +7. Test-first `a38c0b2286aff93ec19a766bcd63927754648e7b` requires the Employment ERROR-log reference to equal the returned `support_reference`. Causal repair `15220135234107de82c481816f19749198c61012` restores canonical shared-emitter ownership and passes the pre-generated reference through both backend-error and generic `_send_error` paths. +8. ADR correction `06390b0d5bc14fdd620ba6f83d3a7973bf0ed9a9` removes the false sibling-signature claim and records the actual checked owner contract. +9. Final acceptance remains deferred until the owner stack reaches protected `develop`, at which point exact-head Foundation/security/SAST/CodeQL/model review, qualifying independent review, and applicable latency evidence must be reacquired. ## Stack authority -Canonical Employment-history application owner #149 is -`d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750` on #55. #155 ordinary-forward -adopted that owner in `554e223ee5133c2a30646c1c048cc004e7f1eb36`; the current HTTP lineage remains -0-behind that direct base and keeps the retired feature-local workflow absent. -The later transport repairs change only #155-owned HTTP source/tests and their -ADR/traceability evidence; no parent-owned source is copied downstream. +Canonical Employment-history application owner #149 remains `d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750` on #55. #155 changes only its HTTP source/tests and feature ADR/traceability; the shared JSON emitter remains parent-owned and is consumed rather than copied. ## Security, availability, and data boundary -The route reads only authorized Employment-version fields and the already- -governed Person/Employment lineage. It does not join Position, Assignment, -compensation, candidate, performance, credential, prompt, or model-output data. -It performs no write, audit/outbox mutation, or high-impact employment decision. - -Caller-controlled path/query work is bounded before route/query parsing, identity, -or persistence work. Authentication failure remains distinguishable as 401, -while identity-backend malfunction or a noncanonical principal object fails closed -as a generic 500 and never reaches authorization/persistence. Bearer credentials -and backend exception text are not copied into customer responses. Synchronous -Employment/PostgreSQL work is worker-thread isolated from the ASGI event loop, -without changing the short read-only transaction owned by the persistence lane. - -The worker-thread repair is not a p95 claim. Production-equivalent connection/pool -settings still require exact-candidate k6/E2E measurement before <=20 ms can be -claimed for this buyer path. +The route reads only authorized Person/Employment history and performs no mutation or employment decision. Authentication backend errors retain one non-secret support reference across route logging and client response. Synchronous Employment/PostgreSQL work is worker-thread isolated from ASGI, but this is not a p95 claim; production-equivalent k6/E2E measurement remains required. ## Out of scope -- Pagination or export workflows. -- Employment correction or mutation workflows. +- Pagination/export. +- Employment correction or mutation. - 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. +- Browser UI/Storybook/Figma for this transport-only slice. +- Release/tag/protected-branch authority. From a6862f7f37184265a4827cf40cfbec0faff8ed09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 18:13:20 +0900 Subject: [PATCH 24/27] test(people): harden Employment ASGI scalar authority --- ...loyment_history_http_boundary_hardening.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/services/people-api/tests/test_employment_history_http_boundary_hardening.py b/services/people-api/tests/test_employment_history_http_boundary_hardening.py index b8705524e..1250f042c 100644 --- a/services/people-api/tests/test_employment_history_http_boundary_hardening.py +++ b/services/people-api/tests/test_employment_history_http_boundary_hardening.py @@ -23,6 +23,28 @@ _SUPPORT_REFERENCE = re.compile(r"^err_[A-Za-z0-9_-]{20,80}$") +class _TrapPath(str): + """Expose execution if a noncanonical path reaches string operations.""" + + def __len__(self) -> int: + raise AssertionError("noncanonical path must be rejected before len()") + + def strip(self, *args: object, **kwargs: object) -> str: + del args, kwargs + raise AssertionError("noncanonical path must be rejected before tokenization") + + +class _TrapQuery(bytes): + """Expose execution if noncanonical query bytes reach parsing.""" + + def __len__(self) -> int: + raise AssertionError("noncanonical query bytes must be rejected before len()") + + def decode(self, *args: object, **kwargs: object) -> str: + del args, kwargs + raise AssertionError("noncanonical query bytes must be rejected before decode()") + + class RecordingAuthenticator: """Return one configured value or raise one configured backend error.""" @@ -115,6 +137,30 @@ async def send(message: dict[str, object]) -> None: start, body = messages return int(start["status"]), json.loads(bytes(body["body"])) + async def test_nonexact_path_fails_before_subclass_behavior_or_authentication(self) -> None: + """Reject executable path subtypes before any path operation or identity work.""" + authenticator = RecordingAuthenticator(self.principal) + port = EmptyHistoryPort() + path = _TrapPath(f"/v1/tenants/{TENANT}/people/{PERSON}/employment-history") + + status, payload = await self._request(self._app(authenticator, port), path=path) + + self.assertEqual((status, payload["error_code"]), (404, "route_not_found")) + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_nonexact_query_fails_before_subclass_behavior_or_authentication(self) -> None: + """Reject executable query subtypes before length/decode or identity work.""" + authenticator = RecordingAuthenticator(self.principal) + port = EmptyHistoryPort() + query = _TrapQuery(DEFAULT_QUERY) + + status, payload = await self._request(self._app(authenticator, port), query=query) + + self.assertEqual((status, payload["error_code"]), (400, "invalid_request")) + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + async def test_authentication_backend_failure_is_client_safe_and_skips_persistence(self) -> None: """An identity-backend exception must retain one support reference end to end.""" authenticator = RecordingAuthenticator( From e1618605d64262f7aea6a7e53692ce0903a946a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 18:13:52 +0900 Subject: [PATCH 25/27] fix(people): harden Employment ASGI scalar authority --- .../src/orgmetra_people_api/employment_history_http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/employment_history_http.py b/services/people-api/src/orgmetra_people_api/employment_history_http.py index 1ff7f77c8..f7a61edb8 100644 --- a/services/people-api/src/orgmetra_people_api/employment_history_http.py +++ b/services/people-api/src/orgmetra_people_api/employment_history_http.py @@ -174,7 +174,7 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send return path = scope.get("path") - if not isinstance(path, str): + if type(path) is not str: await _send_error( send, status=404, @@ -297,7 +297,7 @@ def _parse_employment_history_request(path: str, raw_query: object) -> _ParsedEm if tenant_record_id.int in (0, _MAX_UUID_INT) or person_record_id.int in (0, _MAX_UUID_INT): raise _InvalidHttpRequest("route IDs must be operational UUIDs") - if not isinstance(raw_query, bytes): + if type(raw_query) is not bytes: raise _InvalidHttpRequest("query_string must be bytes") if len(raw_query) > _MAX_QUERY_STRING_BYTES: raise _InvalidHttpRequest("query string exceeds the accepted size") From f511dcd6f7adc6b4162a015263394c41a02af7e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 18:14:19 +0900 Subject: [PATCH 26/27] docs(adr): record Employment scalar authority repair --- docs/adr/0155-employment-history-http-read.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/adr/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md index a076c5949..18c99b8e6 100644 --- a/docs/adr/0155-employment-history-http-read.md +++ b/docs/adr/0155-employment-history-http-read.md @@ -34,7 +34,11 @@ 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/fields, and duplicate-field/parameter rejection before authentication. It caps the path at 256 characters before route tokenization and the raw query at 4096 bytes before -`parse_qsl`; parser field count is bounded too. +`parse_qsl`; parser field count is bounded too. Path and raw-query values must be +exact built-in `str` and `bytes`, not subclasses: caller-defined subtypes can +override `__len__`, `strip`, or `decode`, so accepting them would execute mutable +Python behavior before authentication. Non-exact scalars fail closed before those +operations while preserving the existing 404 path and 400 query response classes. Authentication accepts only exact `AuthenticatedPrincipal`. The route deliberately reuses the canonical People `_send_json` contract inherited through #149/#55. @@ -62,7 +66,8 @@ workflow is not recreated. - Customer Employment history remains a read-only purpose-bound boundary. - Parent service/PostgreSQL owners retain authorization and persistence truth. -- Caller-controlled parsing is bounded before protected work. +- Caller-controlled parsing is bounded before protected work, and executable + path/query scalar subtypes are rejected before overridden behavior can run. - One support reference correlates route logging and the response through the canonical shared emitter. - Synchronous Employment/PostgreSQL work is worker-thread isolated from ASGI; @@ -93,6 +98,15 @@ requires the Employment route's ERROR log reference to equal the response restores canonical emitter ownership and also passes the already-generated reference through `_send_error`, preventing double-reference correlation drift. +Issue #321 then generalized a separately verified transport-integrity mechanism. +Employment-local test-first `a6862f7f37184265a4827cf40cfbec0faff8ed09` +adds trapping `str`/`bytes` subclasses and requires rejection before their +`__len__`, `strip`, or `decode` behavior or any authenticator/persistence call. +The predecessor accepts them through `isinstance(...)`; causal repair +`e1618605d64262f7aea6a7e53692ce0903a946a1` changes the Employment path/query +ingress to exact built-in type checks. Canonical People owner #55 has its own +independent #321 RED/repair; neither lane borrows the other's acceptance verdict. + No hosted RED/GREEN is claimed for these stacked heads because #155 targets #149, not protected `develop`. Final acceptance must be reacquired after the owner stack returns to the protected lane, including Foundation, security, SAST, CodeQL, From 49f5f2542ea0569b0e3b2de8371b7ab88f1b0feb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 18:14:44 +0900 Subject: [PATCH 27/27] docs(trace): bind Employment scalar authority repair --- docs/traceability/employment-history-http-read.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md index 61b47128f..a85210a34 100644 --- a/docs/traceability/employment-history-http-read.md +++ b/docs/traceability/employment-history-http-read.md @@ -12,6 +12,7 @@ PR #149 defines an authorized Employment-history read, but a customer still need | --- | --- | --- | | Validate before protected work | `EmploymentHistoryAsgiApp` parses route, query, UUIDs, UTC cutoff, purpose, and fields before authentication | malformed input cases prove no authenticator or read-port call | | Bound caller-controlled transport work | path length is capped before route tokenization; query bytes and parser field count are bounded before authentication | oversized path/query regressions prove parser/identity/persistence are not reached | +| Reject executable scalar subtypes | path/query ingress requires exact built-in `str`/`bytes` before `len`, `strip`, or `decode` | trapping subtype regressions require 404/400 and zero authentication/persistence calls | | Preserve authenticated identity integrity | only exact `AuthenticatedPrincipal` is accepted; backend malfunction becomes opaque 500 | arbitrary principal and secret-bearing authenticator failures stop before persistence | | Preserve one support reference | route generates/logs one opaque reference and passes it into canonical #55 `_send_json`, which enriches the published `ErrorResponse` without changing the reference | `a38c0b...` compares the Employment route ERROR-log `support_reference` with the response value | | Keep synchronous persistence off the ASGI event loop | synchronous `read_employment_history()` service/PostgreSQL work executes through `asyncio.to_thread(...)` | `d998cd...` records the read-port thread and requires it to differ from the event-loop thread | @@ -29,16 +30,18 @@ PR #149 defines an authorized Employment-history read, but a customer still need 5. Sibling #154 review exposed a synchronous-service-on-ASGI-loop risk. This finding independently reproduces in #155: predecessor `03030dbccba3b6044e2b4d8201203f309d4f40de` directly calls `read_employment_history()`. Test-only `d998cd684adee518b04ddc37cfad7c17ea151d8c` requires the protected read to run off the event-loop thread; `3432b08b67f28cf5e665346494104ec15d523590` introduces `asyncio.to_thread(...)`. 6. The sibling #154 `support_reference` signature finding does **not** reproduce in #155. #155 already inherits canonical #55 `_send_json(..., support_reference=...)`, so the predecessor backend path already returns a schema-enriched response through the shared emitter. The initial sibling-style source edit in `3432b08...` incorrectly omitted that argument, causing the shared emitter to mint a second reference. 7. Test-first `a38c0b2286aff93ec19a766bcd63927754648e7b` requires the Employment ERROR-log reference to equal the returned `support_reference`. Causal repair `15220135234107de82c481816f19749198c61012` restores canonical shared-emitter ownership and passes the pre-generated reference through both backend-error and generic `_send_error` paths. -8. ADR correction `06390b0d5bc14fdd620ba6f83d3a7973bf0ed9a9` removes the false sibling-signature claim and records the actual checked owner contract. -9. Final acceptance remains deferred until the owner stack reaches protected `develop`, at which point exact-head Foundation/security/SAST/CodeQL/model review, qualifying independent review, and applicable latency evidence must be reacquired. +8. Issue #321 identified the executable scalar-subtype mechanism in canonical People and Employment ingress. #55 independently repaired its owner contract (`32585ce...` → `e3c8a1e...`) and #149 ordinary-forward adopted that owner truth in `5de7207c84cda5f5d8e2f358e379748d95acb9e4`. +9. Employment-local RED `a6862f7f37184265a4827cf40cfbec0faff8ed09` adds trapping `str`/`bytes` subclasses. The preceding Employment source accepts them via `isinstance(...)`, allowing overridden `__len__`, `strip`, or `decode` before authentication. Causal repair `e1618605d64262f7aea6a7e53692ce0903a946a1` requires exact built-in scalar types while preserving the existing 404/400 error classes. +10. ADR currentization `f511dcd6f7adc6b4162a015263394c41a02af7e1` records the owner-local rule and keeps ADR 0155 Proposed. +11. Final acceptance remains deferred until the owner stack reaches protected `develop`, at which point exact-head Foundation/security/SAST/CodeQL/model review, qualifying independent review, and applicable latency evidence must be reacquired. ## Stack authority -Canonical Employment-history application owner #149 remains `d2e7d0c1fc038bf151466ad8e1ce1a3074d2d750` on #55. #155 changes only its HTTP source/tests and feature ADR/traceability; the shared JSON emitter remains parent-owned and is consumed rather than copied. +Canonical Employment-history application owner #149 is now `5de7207c84cda5f5d8e2f358e379748d95acb9e4` on #55 `e3c8a1efabdae3da2c33fc75cb27d925e2b60c9b`. #155 consumes that parent-owned People transport repair and separately owns its Employment route/parser regression; it does not copy mutable owner implementation into the Employment bounded context. ## Security, availability, and data boundary -The route reads only authorized Person/Employment history and performs no mutation or employment decision. Authentication backend errors retain one non-secret support reference across route logging and client response. Synchronous Employment/PostgreSQL work is worker-thread isolated from ASGI, but this is not a p95 claim; production-equivalent k6/E2E measurement remains required. +The route reads only authorized Person/Employment history and performs no mutation or employment decision. Path/query transport authority is reduced to exact inert built-in scalar types before parser work. Authentication backend errors retain one non-secret support reference across route logging and client response. Synchronous Employment/PostgreSQL work is worker-thread isolated from ASGI, but this is not a p95 claim; production-equivalent k6/E2E measurement remains required. ## Out of scope