From 48f91850868dea86377d3e926ad0d9a45b5094dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:15:25 -0700 Subject: [PATCH 001/269] test(hire): reject identity runtime subclasses --- .../test_hire_identity_runtime_integrity.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 services/people-api/tests/test_hire_identity_runtime_integrity.py diff --git a/services/people-api/tests/test_hire_identity_runtime_integrity.py b/services/people-api/tests/test_hire_identity_runtime_integrity.py new file mode 100644 index 000000000..88018ff9e --- /dev/null +++ b/services/people-api/tests/test_hire_identity_runtime_integrity.py @@ -0,0 +1,75 @@ +"""Runtime identity-integrity regressions for confirmed-hire contracts.""" + +from __future__ import annotations + +from datetime import date +from uuid import UUID + +import pytest + +from orgmetra_people_api.hire import HireAcceptanceCommand, HireAcceptanceResult + + +class _ForgedUUID(UUID): + """Attempt to make immutable hire evidence render a different identity.""" + + def __str__(self) -> str: + """Render a caller-chosen UUID instead of the underlying value.""" + return "0198a412-7000-7000-8000-ffffffffffff" + + +def _command(**overrides: object) -> HireAcceptanceCommand: + """Build one otherwise-valid confirmed-hire command.""" + values: dict[str, object] = { + "tenant_record_id": UUID("0198a412-7000-7000-8000-000000000001"), + "candidate_profile_id": UUID("0198a412-7000-7000-8000-000000000010"), + "selection_decision_id": UUID("0198a412-7000-7000-8000-000000000011"), + "person_record_id": UUID("0198a412-7000-7000-8000-000000000020"), + "person_name_record_id": UUID("0198a412-7000-7000-8000-000000000021"), + "employment_record_id": UUID("0198a412-7000-7000-8000-000000000030"), + "employment_record_version_id": UUID("0198a412-7000-7000-8000-000000000031"), + "candidate_worker_conversion_record_id": UUID("0198a412-7000-7000-8000-000000000040"), + "audit_event_record_id": UUID("0198a412-7000-7000-8000-000000000050"), + "outbox_delivery_record_id": UUID("0198a412-7000-7000-8000-000000000051"), + "effective_from": date(2026, 8, 21), + "display_name": "Ada Lovelace", + "idempotency_key": "hire-runtime-integrity-21", + "employment_status_code": "active", + } + values.update(overrides) + return HireAcceptanceCommand(**values) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "field_name", + [ + "tenant_record_id", + "candidate_profile_id", + "selection_decision_id", + "person_record_id", + "person_name_record_id", + "employment_record_id", + "employment_record_version_id", + "candidate_worker_conversion_record_id", + "audit_event_record_id", + "outbox_delivery_record_id", + ], +) +def test_hire_command_rejects_uuid_subclasses_before_idempotency_or_persistence( + field_name: str, +) -> None: + """Caller-controlled UUID rendering cannot rewrite confirmed-hire semantics.""" + forged = _ForgedUUID("0198a412-7000-7000-8000-000000000123") + with pytest.raises(ValueError, match=f"{field_name} must be an operational UUID"): + _command(**{field_name: forged}) + + +def test_hire_result_rejects_uuid_subclasses_before_crossing_service_boundary() -> None: + """A persistence adapter cannot return identity objects with forged rendering.""" + forged = _ForgedUUID("0198a412-7000-7000-8000-000000000123") + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + HireAcceptanceResult( + person_record_id=forged, + employment_record_id=UUID("0198a412-7000-7000-8000-000000000030"), + candidate_worker_conversion_record_id=UUID("0198a412-7000-7000-8000-000000000040"), + ) From ef3f9959047a1516e75311aa8ded0b7d841c4ef7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:16:12 -0700 Subject: [PATCH 002/269] test(hire): reject validation-bypassing contract subclasses --- .../test_hire_identity_runtime_integrity.py | 120 +++++++++++++++++- 1 file changed, 115 insertions(+), 5 deletions(-) diff --git a/services/people-api/tests/test_hire_identity_runtime_integrity.py b/services/people-api/tests/test_hire_identity_runtime_integrity.py index 88018ff9e..1b3a44c23 100644 --- a/services/people-api/tests/test_hire_identity_runtime_integrity.py +++ b/services/people-api/tests/test_hire_identity_runtime_integrity.py @@ -7,7 +7,15 @@ import pytest -from orgmetra_people_api.hire import HireAcceptanceCommand, HireAcceptanceResult +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import ( + HireAcceptanceCommand, + HireAcceptanceResult, + accept_confirmed_hire, +) + +TENANT = UUID("0198a412-7000-7000-8000-000000000001") class _ForgedUUID(UUID): @@ -18,10 +26,24 @@ def __str__(self) -> str: return "0198a412-7000-7000-8000-ffffffffffff" -def _command(**overrides: object) -> HireAcceptanceCommand: - """Build one otherwise-valid confirmed-hire command.""" +class _UnvalidatedHireCommand(HireAcceptanceCommand): + """Attempt to bypass base dataclass validation through dynamic post-init dispatch.""" + + def __post_init__(self) -> None: + """Intentionally skip the governed base validation.""" + + +class _UnvalidatedHireResult(HireAcceptanceResult): + """Attempt to return malformed persistence evidence through a result subclass.""" + + def __post_init__(self) -> None: + """Intentionally skip the governed base validation.""" + + +def _command_values(**overrides: object) -> dict[str, object]: + """Return one otherwise-valid confirmed-hire command mapping.""" values: dict[str, object] = { - "tenant_record_id": UUID("0198a412-7000-7000-8000-000000000001"), + "tenant_record_id": TENANT, "candidate_profile_id": UUID("0198a412-7000-7000-8000-000000000010"), "selection_decision_id": UUID("0198a412-7000-7000-8000-000000000011"), "person_record_id": UUID("0198a412-7000-7000-8000-000000000020"), @@ -37,7 +59,64 @@ def _command(**overrides: object) -> HireAcceptanceCommand: "employment_status_code": "active", } values.update(overrides) - return HireAcceptanceCommand(**values) # type: ignore[arg-type] + return values + + +def _command(**overrides: object) -> HireAcceptanceCommand: + """Build one otherwise-valid confirmed-hire command.""" + return HireAcceptanceCommand(**_command_values(**overrides)) # type: ignore[arg-type] + + +def _principal() -> AuthenticatedPrincipal: + """Return a principal authorized for the focused application-boundary tests.""" + return AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-21", + granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), + ) + + +def _policy() -> PurposeBoundAccessPolicy: + """Return the exact purpose-bound policy for confirmed-hire materialization.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people-hire-v1", + resource_kind="selection_decision", + purpose_code="candidate_hire", + operation_code="materialize_worker", + required_scope_code="orgmetra.people.materialize_worker", + permitted_fields=frozenset({"candidate_worker_conversion"}), + ) + + +class _RecordingPort: + """Capture whether malformed commands cross the governed application boundary.""" + + def __init__(self) -> None: + self.called = False + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Return a valid opaque result while recording the call.""" + del authorization + self.called = True + return HireAcceptanceResult( + person_record_id=command.person_record_id, + employment_record_id=command.employment_record_id, + candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, + ) + + +class _MalformedResultPort: + """Return an invalid subclass that skipped the result contract's post-init checks.""" + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Produce malformed result evidence after a valid authorization call.""" + del command, authorization + return _UnvalidatedHireResult( + person_record_id="not-a-uuid", # type: ignore[arg-type] + employment_record_id=UUID("0198a412-7000-7000-8000-000000000030"), + candidate_worker_conversion_record_id=UUID("0198a412-7000-7000-8000-000000000040"), + ) @pytest.mark.parametrize( @@ -73,3 +152,34 @@ def test_hire_result_rejects_uuid_subclasses_before_crossing_service_boundary() employment_record_id=UUID("0198a412-7000-7000-8000-000000000030"), candidate_worker_conversion_record_id=UUID("0198a412-7000-7000-8000-000000000040"), ) + + +def test_confirmed_hire_rejects_command_subclass_that_bypassed_post_init() -> None: + """Only an exact validated command may cross into authoritative persistence.""" + forged = _UnvalidatedHireCommand( + **_command_values(effective_from="not-a-business-date") # type: ignore[arg-type] + ) + port = _RecordingPort() + + with pytest.raises(TypeError, match="command must be a HireAcceptanceCommand"): + accept_confirmed_hire( + principal=_principal(), + command=forged, + purpose_code="candidate_hire", + policy=_policy(), + mutation_port=port, + ) + + assert port.called is False + + +def test_confirmed_hire_rejects_result_subclass_that_bypassed_post_init() -> None: + """Only an exact validated result may leave the authoritative mutation boundary.""" + with pytest.raises(TypeError, match="mutation_port must return HireAcceptanceResult"): + accept_confirmed_hire( + principal=_principal(), + command=_command(), + purpose_code="candidate_hire", + policy=_policy(), + mutation_port=_MalformedResultPort(), + ) From a78edadd06ca604d5f60f54dd8fed4962465618e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:16:29 -0700 Subject: [PATCH 003/269] fix(hire): protect governed identity and contract runtime types --- services/people-api/src/orgmetra_people_api/hire.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 6823f4c59..407bd9870 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -35,8 +35,8 @@ class HireDecisionIntegrityError(RuntimeError): def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require a real UUID outside Orgmetra's reserved protocol sentinels.""" - if not isinstance(value, UUID) or value.int in (0, _MAX_UUID_INT): + """Require an exact UUID outside Orgmetra's reserved protocol sentinels.""" + if type(value) is not UUID or value.int in (0, _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") @@ -147,7 +147,7 @@ def accept_confirmed_hire( ``materialize_worker`` operation and ``candidate_worker_conversion`` field; possession of an identity token or purpose string alone is insufficient. """ - if not isinstance(command, HireAcceptanceCommand): + if type(command) is not HireAcceptanceCommand: raise TypeError("command must be a HireAcceptanceCommand") if not isinstance(mutation_port, HireAcceptancePort): raise TypeError("mutation_port must implement HireAcceptancePort") @@ -164,6 +164,6 @@ def accept_confirmed_hire( policy=policy, ) result = mutation_port.accept_hire(command=command, authorization=authorization) - if not isinstance(result, HireAcceptanceResult): + if type(result) is not HireAcceptanceResult: raise TypeError("mutation_port must return HireAcceptanceResult") return result From 15c3ffc3119ee7b103b5da434ef4a7b2b2c179fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:21:10 -0700 Subject: [PATCH 004/269] test(people): reject mutation runtime type confusion --- .../test_people_mutation_runtime_integrity.py | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_runtime_integrity.py diff --git a/services/people-api/tests/test_people_mutation_runtime_integrity.py b/services/people-api/tests/test_people_mutation_runtime_integrity.py new file mode 100644 index 000000000..2b40dc67f --- /dev/null +++ b/services/people-api/tests/test_people_mutation_runtime_integrity.py @@ -0,0 +1,236 @@ +"""Runtime-integrity regressions for authoritative People mutations.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + EmploymentMutationCommand, + EmploymentMutationResult, + command_route, + create_employment_record, + mutation_command_digest, +) + +TENANT = UUID("0198a412-8000-7000-8000-000000000001") +PERSON = UUID("0198a412-8000-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-8000-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-8000-7000-8000-000000000031") +AUDIT_EVENT = UUID("0198a412-8000-7000-8000-000000000080") +OUTBOX = UUID("0198a412-8000-7000-8000-000000000081") + + +class _ForgedUUID(UUID): + """Attempt to rewrite mutation identity text during canonical digesting.""" + + def __str__(self) -> str: + """Render an identity different from the underlying UUID.""" + return "0198a412-8000-7000-8000-ffffffffffff" + + +class _ForgedDecimal(Decimal): + """Attempt to rewrite an assignment ratio during canonical digesting.""" + + def __format__(self, spec: str) -> str: + """Render a ratio different from the underlying Decimal value.""" + del spec + return "0.9999" + + +class _UnvalidatedEmploymentCommand(EmploymentMutationCommand): + """Attempt to bypass base command validation through post-init dispatch.""" + + def __post_init__(self) -> None: + """Intentionally skip the governed base validation.""" + + +class _UnvalidatedEmploymentResult(EmploymentMutationResult): + """Attempt to bypass persistence-result validation.""" + + def __post_init__(self) -> None: + """Intentionally skip the governed base validation.""" + + +def _employment_values(**overrides: object) -> dict[str, object]: + """Return one otherwise-valid employment mutation command mapping.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "person_record_id": PERSON, + "employment_record_id": EMPLOYMENT, + "employment_record_version_id": EMPLOYMENT_VERSION, + "audit_event_record_id": AUDIT_EVENT, + "outbox_delivery_record_id": OUTBOX, + "employment_status_code": "active", + "employment_concurrency_code": "exclusive", + "effective_from": date(2026, 8, 21), + "confirmation_reference": "human_confirmation:runtime-21", + "evidence_version_code": "decision_evidence_set:v1", + "idempotency_key": "mutation-runtime-key-21", + } + values.update(overrides) + return values + + +def _employment(**overrides: object) -> EmploymentMutationCommand: + """Build one exact employment mutation command.""" + return EmploymentMutationCommand(**_employment_values(**overrides)) # type: ignore[arg-type] + + +def _assignment(**overrides: object) -> AssignmentMutationCommand: + """Build one exact assignment mutation command.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "employment_record_id": EMPLOYMENT, + "person_record_id": PERSON, + "position_record_id": UUID("0198a412-8000-7000-8000-000000000040"), + "assignment_record_id": UUID("0198a412-8000-7000-8000-000000000070"), + "audit_event_record_id": AUDIT_EVENT, + "outbox_delivery_record_id": OUTBOX, + "allocation_ratio": Decimal("1.0000"), + "effective_from": date(2026, 8, 21), + "confirmation_reference": "human_confirmation:runtime-21", + "evidence_version_code": "decision_evidence_set:v1", + "idempotency_key": "mutation-runtime-key-21", + } + values.update(overrides) + return AssignmentMutationCommand(**values) # type: ignore[arg-type] + + +def _decision() -> AuthorizationDecision: + """Build one minimal exact authorization decision for digest testing.""" + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-21", + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + policy_version_code="people-mutation-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind="employment_record", + requested_fields=frozenset({"employment_record"}), + authorized_fields=frozenset({"employment_record"}), + reason_code="access_permitted", + next_action="Continue with only the authorized fields.", + ) + + +def test_mutation_command_rejects_uuid_subclass_before_digest_or_persistence() -> None: + """Caller-controlled UUID rendering cannot rewrite People mutation identity.""" + forged = _ForgedUUID("0198a412-8000-7000-8000-000000000123") + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + _employment(person_record_id=forged) + + +def test_mutation_result_rejects_uuid_subclass_before_service_return() -> None: + """Persistence cannot return an identity object with forged rendering.""" + forged = _ForgedUUID("0198a412-8000-7000-8000-000000000123") + with pytest.raises(ValueError, match="employment_record_id must be an operational UUID"): + EmploymentMutationResult(employment_record_id=forged) + + +def test_assignment_rejects_decimal_subclass_before_canonical_ratio_digest() -> None: + """Allocation evidence cannot invoke caller-controlled Decimal formatting.""" + forged = _ForgedDecimal("0.5000") + with pytest.raises(ValueError, match="allocation_ratio must be a Decimal"): + _assignment(allocation_ratio=forged) + + +def test_command_helpers_reject_validation_bypassing_subclasses() -> None: + """Routing and digest helpers require exact validated mutation commands.""" + forged = _UnvalidatedEmploymentCommand( + **_employment_values(person_record_id="not-a-uuid") # type: ignore[arg-type] + ) + with pytest.raises(TypeError, match="governed People mutation command"): + command_route(forged) + with pytest.raises(TypeError, match="governed People mutation command"): + mutation_command_digest(command=forged, authorization=_decision()) + + +def test_create_employment_rejects_command_subclass_before_authorization_or_port() -> None: + """A command that skipped post-init validation cannot reach the mutation port.""" + forged = _UnvalidatedEmploymentCommand( + **_employment_values(person_record_id="not-a-uuid") # type: ignore[arg-type] + ) + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-21", + granted_scope_codes=frozenset({"orgmetra.people.write"}), + ) + policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people-mutation-v1", + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="create_record", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({"employment_record"}), + ) + + class _Port: + def create_employment(self, *, command: EmploymentMutationCommand, authorization: object) -> EmploymentMutationResult: + del command, authorization + pytest.fail("validation-bypassing command reached persistence") + + def create_position(self, *, command: object, authorization: object) -> object: + del command, authorization + raise AssertionError + + def create_assignment(self, *, command: object, authorization: object) -> object: + del command, authorization + raise AssertionError + + with pytest.raises(TypeError, match="command must be an EmploymentMutationCommand"): + create_employment_record( + principal=principal, + command=forged, + purpose_code="workforce_admin", + policy=policy, + mutation_port=_Port(), # type: ignore[arg-type] + ) + + +def test_create_employment_rejects_result_subclass_that_skipped_validation() -> None: + """Malformed result subclasses cannot cross the authoritative mutation boundary.""" + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-21", + granted_scope_codes=frozenset({"orgmetra.people.write"}), + ) + policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people-mutation-v1", + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="create_record", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({"employment_record"}), + ) + + class _Port: + def create_employment(self, *, command: EmploymentMutationCommand, authorization: object) -> EmploymentMutationResult: + del command, authorization + return _UnvalidatedEmploymentResult(employment_record_id="not-a-uuid") # type: ignore[arg-type] + + def create_position(self, *, command: object, authorization: object) -> object: + del command, authorization + raise AssertionError + + def create_assignment(self, *, command: object, authorization: object) -> object: + del command, authorization + raise AssertionError + + with pytest.raises(TypeError, match="mutation_port must return EmploymentMutationResult"): + create_employment_record( + principal=principal, + command=_employment(), + purpose_code="workforce_admin", + policy=policy, + mutation_port=_Port(), # type: ignore[arg-type] + ) From 6f9db105ce36f97e0e5c1174066cbd133956bb61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:21:34 -0700 Subject: [PATCH 005/269] test(people): reject idempotency evidence runtime confusion --- ...eople_mutation_digest_runtime_integrity.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_digest_runtime_integrity.py diff --git a/services/people-api/tests/test_people_mutation_digest_runtime_integrity.py b/services/people-api/tests/test_people_mutation_digest_runtime_integrity.py new file mode 100644 index 000000000..efb67db23 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_digest_runtime_integrity.py @@ -0,0 +1,104 @@ +"""Runtime-integrity regressions for People mutation idempotency evidence.""" + +from __future__ import annotations + +from datetime import date +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.mutations import ( + EmploymentMutationCommand, + idempotency_record_id, + mutation_command_digest, +) + +TENANT = UUID("0198a412-8000-7000-8000-000000000001") + + +class _ForgedUUID(UUID): + """Attempt to select idempotency identity with caller-controlled string rendering.""" + + def __str__(self) -> str: + """Render another tenant identifier while retaining the original UUID value.""" + return "0198a412-8000-7000-8000-ffffffffffff" + + +class _ForgedDecision(AuthorizationDecision): + """Attempt to rewrite immutable authorization evidence during digest construction.""" + + def __getattribute__(self, name: str) -> object: + """Forge only the actor value observed by digest construction.""" + if name == "actor_reference": + return "keyverse_subject:forged-actor" + return super().__getattribute__(name) + + +def _command() -> EmploymentMutationCommand: + """Build one exact employment mutation command.""" + return EmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=UUID("0198a412-8000-7000-8000-000000000020"), + employment_record_id=UUID("0198a412-8000-7000-8000-000000000030"), + employment_record_version_id=UUID("0198a412-8000-7000-8000-000000000031"), + audit_event_record_id=UUID("0198a412-8000-7000-8000-000000000080"), + outbox_delivery_record_id=UUID("0198a412-8000-7000-8000-000000000081"), + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=date(2026, 8, 21), + confirmation_reference="human_confirmation:runtime-21", + evidence_version_code="decision_evidence_set:v1", + idempotency_key="mutation-runtime-key-21", + ) + + +def _decision() -> AuthorizationDecision: + """Build one exact authorized mutation decision.""" + employment_id = _command().employment_record_id + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-21", + resource_reference=f"employment_record:{employment_id.hex}", + policy_version_code="people-mutation-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind="employment_record", + requested_fields=frozenset({"employment_record"}), + authorized_fields=frozenset({"employment_record"}), + reason_code="access_permitted", + next_action="Continue with only the authorized fields.", + ) + + +def test_idempotency_record_id_rejects_uuid_subclass_before_tenant_key_derivation() -> None: + """Idempotency identity cannot be derived from caller-controlled tenant rendering.""" + forged = _ForgedUUID("0198a412-8000-7000-8000-000000000001") + with pytest.raises(ValueError, match="tenant_record_id must be an operational UUID"): + idempotency_record_id( + tenant_record_id=forged, + command_route_value="employment-records", + idempotency_key="mutation-runtime-key-21", + ) + + +def test_mutation_digest_rejects_authorization_decision_subclasses() -> None: + """Digest evidence must use the exact decision produced by the authorization adapter.""" + base = _decision() + forged = _ForgedDecision( + allowed=base.allowed, + tenant_record_id=base.tenant_record_id, + actor_reference=base.actor_reference, + resource_reference=base.resource_reference, + policy_version_code=base.policy_version_code, + purpose_code=base.purpose_code, + operation_code=base.operation_code, + resource_kind=base.resource_kind, + requested_fields=base.requested_fields, + authorized_fields=base.authorized_fields, + reason_code=base.reason_code, + next_action=base.next_action, + ) + with pytest.raises(TypeError, match="authorization must be an AuthorizationDecision"): + mutation_command_digest(command=_command(), authorization=forged) From e98cd1c745c815138b4d171fe1ea1eebf209a3d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:22:28 -0700 Subject: [PATCH 006/269] fix(people): protect mutation identity and idempotency runtime types --- .../src/orgmetra_people_api/mutations.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 6baeac684..bc8a6add9 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -47,8 +47,8 @@ class PeopleMutationIntegrityError(RuntimeError): def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require a real UUID outside Orgmetra's reserved protocol sentinels.""" - if not isinstance(value, UUID) or value.int in (0, _MAX_UUID_INT): + """Require an exact UUID outside Orgmetra's reserved protocol sentinels.""" + if type(value) is not UUID or value.int in (0, _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") @@ -83,11 +83,11 @@ def command_route( command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, ) -> str: """Return the durable route that scopes one People mutation idempotency key.""" - if isinstance(command, EmploymentMutationCommand): + if type(command) is EmploymentMutationCommand: return "employment-records" - if isinstance(command, PositionMutationCommand): + if type(command) is PositionMutationCommand: return "position-records" - if isinstance(command, AssignmentMutationCommand): + if type(command) is AssignmentMutationCommand: return "assignment-records" raise TypeError("command must be a governed People mutation command") @@ -99,6 +99,7 @@ def idempotency_record_id( idempotency_key: str, ) -> UUID: """Derive a stable operational identity for one tenant/route/key binding.""" + _validate_operational_uuid("tenant_record_id", tenant_record_id) return uuid5( _IDEMPOTENCY_NAMESPACE, f"{tenant_record_id}:{command_route_value}:{idempotency_key}", @@ -115,9 +116,9 @@ def mutation_command_digest( Generated record identifiers are excluded so a retry that allocates fresh UUIDs still matches the first committed command. """ - if not isinstance(authorization, AuthorizationDecision): + if type(authorization) is not AuthorizationDecision: raise TypeError("authorization must be an AuthorizationDecision") - if isinstance(command, EmploymentMutationCommand): + if type(command) is EmploymentMutationCommand: route = "employment-records" semantic_command: dict[str, object] = { "confirmation_reference": command.confirmation_reference, @@ -127,7 +128,7 @@ def mutation_command_digest( "evidence_version_code": command.evidence_version_code, "person_record_id": str(command.person_record_id), } - elif isinstance(command, PositionMutationCommand): + elif type(command) is PositionMutationCommand: route = "position-records" semantic_command = { "confirmation_reference": command.confirmation_reference, @@ -137,7 +138,7 @@ def mutation_command_digest( "organization_unit_id": str(command.organization_unit_id), "position_status_code": command.position_status_code, } - elif isinstance(command, AssignmentMutationCommand): + elif type(command) is AssignmentMutationCommand: route = "assignment-records" semantic_command = { "allocation_ratio": _canonical_allocation_ratio(command.allocation_ratio), @@ -272,7 +273,7 @@ def __post_init__(self) -> None: _validate_operational_uuid(field_name, getattr(self, field_name)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") - if not isinstance(self.allocation_ratio, Decimal): + if type(self.allocation_ratio) is not Decimal: raise ValueError("allocation_ratio must be a Decimal.") if not self.allocation_ratio.is_finite(): raise ValueError("allocation_ratio must be finite.") @@ -363,7 +364,7 @@ def create_employment_record( mutation_port: PeopleMutationPort, ) -> EmploymentMutationResult: """Authorize the exact employment target before persisting worker employment truth.""" - if not isinstance(command, EmploymentMutationCommand): + if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") port = _require_port(mutation_port) authorization = authorize_resource_fields( @@ -378,7 +379,7 @@ def create_employment_record( policy=policy, ) result = port.create_employment(command=command, authorization=authorization) - if not isinstance(result, EmploymentMutationResult): + if type(result) is not EmploymentMutationResult: raise TypeError("mutation_port must return EmploymentMutationResult") return result @@ -392,7 +393,7 @@ def create_position_record( mutation_port: PeopleMutationPort, ) -> PositionMutationResult: """Authorize the exact position target before persisting a staffable seat.""" - if not isinstance(command, PositionMutationCommand): + if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") port = _require_port(mutation_port) authorization = authorize_resource_fields( @@ -407,7 +408,7 @@ def create_position_record( policy=policy, ) result = port.create_position(command=command, authorization=authorization) - if not isinstance(result, PositionMutationResult): + if type(result) is not PositionMutationResult: raise TypeError("mutation_port must return PositionMutationResult") return result @@ -421,7 +422,7 @@ def create_assignment_record( mutation_port: PeopleMutationPort, ) -> AssignmentMutationResult: """Authorize the exact assignment target before persisting seat allocation.""" - if not isinstance(command, AssignmentMutationCommand): + if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") port = _require_port(mutation_port) authorization = authorize_resource_fields( @@ -436,7 +437,7 @@ def create_assignment_record( policy=policy, ) result = port.create_assignment(command=command, authorization=authorization) - if not isinstance(result, AssignmentMutationResult): + if type(result) is not AssignmentMutationResult: raise TypeError("mutation_port must return AssignmentMutationResult") return result From ad38eb452ac1b072a146b5edf41a64a8e6553c78 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:32:10 -0700 Subject: [PATCH 007/269] test(people): reject forged hire authority runtime types --- ...stgres_hire_authority_runtime_integrity.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 services/people-api/tests/test_postgres_hire_authority_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_hire_authority_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_authority_runtime_integrity.py new file mode 100644 index 000000000..ff45c5232 --- /dev/null +++ b/services/people-api/tests/test_postgres_hire_authority_runtime_integrity.py @@ -0,0 +1,99 @@ +"""Adversarial runtime-integrity contracts for the PostgreSQL hire authority boundary.""" + +from __future__ import annotations + +from datetime import date +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.hire import HireAcceptanceCommand, HireDecisionIntegrityError +from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort + +TENANT = UUID("0198a412-7100-7000-8000-000000000001") +CANDIDATE = UUID("0198a412-7100-7000-8000-000000000010") +DECISION = UUID("0198a412-7100-7000-8000-000000000011") +PERSON = UUID("0198a412-7100-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-7100-7000-8000-000000000021") +EMPLOYMENT = UUID("0198a412-7100-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-7100-7000-8000-000000000031") +CONVERSION = UUID("0198a412-7100-7000-8000-000000000040") +AUDIT_EVENT = UUID("0198a412-7100-7000-8000-000000000050") +OUTBOX_DELIVERY = UUID("0198a412-7100-7000-8000-000000000051") +ACTOR = "keyverse_subject:operator-17" +PURPOSE = "candidate_hire" + + +class ForgedHireAcceptanceCommand(HireAcceptanceCommand): + """Represent a validation-bypassing caller-defined hire command subtype.""" + + +class ForgedAuthorizationDecision(AuthorizationDecision): + """Represent a caller-defined authorization subtype at a trust boundary.""" + + +def _command(command_type: type[HireAcceptanceCommand] = HireAcceptanceCommand) -> HireAcceptanceCommand: + """Build one deterministic valid hire command using the requested runtime type.""" + return command_type( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX_DELIVERY, + effective_from=date(2026, 8, 18), + display_name="Ada Lovelace", + idempotency_key="hire-authority-runtime-integrity", + ) + + +def _authorization( + authorization_type: type[AuthorizationDecision] = AuthorizationDecision, +) -> AuthorizationDecision: + """Build one deterministic exact-scope allow decision using the requested runtime type.""" + return authorization_type( + allowed=True, + tenant_record_id=TENANT, + actor_reference=ACTOR, + resource_reference=f"selection_decision:{DECISION.hex}", + policy_version_code="people-hire-v1", + purpose_code=PURPOSE, + operation_code="materialize_worker", + resource_kind="selection_decision", + requested_fields=frozenset({"candidate_worker_conversion"}), + authorized_fields=frozenset({"candidate_worker_conversion"}), + reason_code="access_permitted", + next_action="continue", + ) + + +def _forbidden_connection_factory() -> object: + """Fail the regression if untrusted runtime input reaches database work.""" + raise AssertionError("database work must not begin for forged runtime authority objects") + + +def test_postgres_hire_port_rejects_command_subclass_before_database_work() -> None: + """Require the persistence authority to accept only the exact governed command type.""" + port = PostgresHireAcceptancePort(_forbidden_connection_factory) + + with pytest.raises(TypeError, match="command must be a HireAcceptanceCommand"): + port.accept_hire( + command=_command(ForgedHireAcceptanceCommand), + authorization=_authorization(), + ) + + +def test_postgres_hire_port_rejects_authorization_subclass_before_database_work() -> None: + """Require the persistence authority to accept only the exact governed authorization type.""" + port = PostgresHireAcceptancePort(_forbidden_connection_factory) + + with pytest.raises(HireDecisionIntegrityError, match="typed authorization decision"): + port.accept_hire( + command=_command(), + authorization=_authorization(ForgedAuthorizationDecision), + ) From 63eb051935df4291b2e423189503c0aec71b6ff1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:32:51 -0700 Subject: [PATCH 008/269] fix(people): require exact hire authority runtime types --- .../people-api/src/orgmetra_people_api/postgres_hire.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 4c328e02f..705caefe4 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -176,7 +176,7 @@ def _is_aware_datetime(value: object) -> bool: def _validate_authorization(command: HireAcceptanceCommand, authorization: object) -> AuthorizationDecision: """Require an exact allow decision for this immutable selection decision.""" expected_reference = f"selection_decision:{command.selection_decision_id.hex}" - if not isinstance(authorization, AuthorizationDecision): + if type(authorization) is not AuthorizationDecision: raise HireDecisionIntegrityError("hire mutation requires a typed authorization decision") if ( not authorization.allowed @@ -304,7 +304,7 @@ def accept_hire( authority. Tenant/route/key advisory serialization prevents concurrent retries from racing the unique idempotency binding. """ - if not isinstance(command, HireAcceptanceCommand): + if type(command) is not HireAcceptanceCommand: raise TypeError("command must be a HireAcceptanceCommand") decision = _validate_authorization(command, authorization) @@ -453,4 +453,4 @@ def accept_hire( person_record_id=command.person_record_id, employment_record_id=command.employment_record_id, candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, - ) + ) \ No newline at end of file From 27623490127834413c9ffd2dc900cb06e6ca00a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:34:28 -0700 Subject: [PATCH 009/269] test(people): reject forged mutation authorization subtype --- ...utation_authorization_runtime_integrity.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 services/people-api/tests/test_postgres_mutation_authorization_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_mutation_authorization_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_authorization_runtime_integrity.py new file mode 100644 index 000000000..0273614cb --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_authorization_runtime_integrity.py @@ -0,0 +1,48 @@ +"""Adversarial runtime-integrity contract for PostgreSQL People mutation authorization.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.postgres_mutations import ( + PeopleMutationIntegrityError, + _require_authorization, +) + +TENANT = UUID("0198a412-7100-7000-8000-000000000001") +RESOURCE = "employment_record:0198a412710070008000000000000030" +FIELDS = frozenset({"employment_record"}) + + +class ForgedAuthorizationDecision(AuthorizationDecision): + """Represent a validation-bypassing caller-defined authorization subtype.""" + + +def test_postgres_people_mutation_rejects_authorization_subclass() -> None: + """Require persistence authorization to use the exact governed decision runtime type.""" + forged = ForgedAuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + resource_reference=RESOURCE, + policy_version_code="people-employment-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind="employment_record", + requested_fields=FIELDS, + authorized_fields=FIELDS, + reason_code="access_permitted", + next_action="continue", + ) + + with pytest.raises(PeopleMutationIntegrityError, match="typed authorization decision"): + _require_authorization( + authorization=forged, + tenant_record_id=TENANT, + resource_reference=RESOURCE, + resource_kind="employment_record", + requested_fields=FIELDS, + ) From 76cb5b0d963ce5c2d273f23f8dce01444a38499a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:36:01 -0700 Subject: [PATCH 010/269] fix(people): require exact mutation authorization type --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index d94832cf8..1bfb9b086 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -322,7 +322,7 @@ def _require_authorization( requested_fields: frozenset[str], ) -> AuthorizationDecision: """Require an exact allow decision for the intended mutation target.""" - if not isinstance(authorization, AuthorizationDecision): + if type(authorization) is not AuthorizationDecision: raise PeopleMutationIntegrityError("people mutation requires a typed authorization decision") if ( not authorization.allowed From e859d208766463be243dd84443a615fbc2c83da1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:40:12 -0700 Subject: [PATCH 011/269] test(people): reject forged mutation command subtypes --- ...gres_mutation_command_runtime_integrity.py | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 services/people-api/tests/test_postgres_mutation_command_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_mutation_command_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_command_runtime_integrity.py new file mode 100644 index 000000000..9ba409249 --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_command_runtime_integrity.py @@ -0,0 +1,140 @@ +"""Adversarial runtime-integrity contracts for PostgreSQL People mutation commands.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + EmploymentMutationCommand, + PositionMutationCommand, +) +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort + +TENANT = UUID("0198a412-7100-7000-8000-000000000001") +PERSON = UUID("0198a412-7100-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-7100-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-7100-7000-8000-000000000031") +ORGANIZATION = UUID("0198a412-7100-7000-8000-000000000040") +JOB = UUID("0198a412-7100-7000-8000-000000000041") +POSITION = UUID("0198a412-7100-7000-8000-000000000050") +POSITION_VERSION = UUID("0198a412-7100-7000-8000-000000000051") +ASSIGNMENT = UUID("0198a412-7100-7000-8000-000000000060") +AUDIT = UUID("0198a412-7100-7000-8000-000000000070") +OUTBOX = UUID("0198a412-7100-7000-8000-000000000071") + + +class ForgedEmploymentMutationCommand(EmploymentMutationCommand): + """Represent a validation-bypassing caller-defined employment command subtype.""" + + +class ForgedPositionMutationCommand(PositionMutationCommand): + """Represent a validation-bypassing caller-defined position command subtype.""" + + +class ForgedAssignmentMutationCommand(AssignmentMutationCommand): + """Represent a validation-bypassing caller-defined assignment command subtype.""" + + +def _authorization(resource_kind: str, record_id: UUID) -> AuthorizationDecision: + """Build one exact-scope allow decision for a People mutation target.""" + fields = frozenset({resource_kind}) + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + resource_reference=f"{resource_kind}:{record_id.hex}", + policy_version_code="people-mutation-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind=resource_kind, + requested_fields=fields, + authorized_fields=fields, + reason_code="access_permitted", + next_action="continue", + ) + + +def _forbidden_connection_factory() -> object: + """Fail if a forged command crosses the persistence authority into database work.""" + raise AssertionError("database work must not begin for a forged People mutation command") + + +def test_postgres_employment_port_rejects_command_subclass_before_database_work() -> None: + """Require the employment persistence authority to accept only its exact command type.""" + command = ForgedEmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + audit_event_record_id=AUDIT, + outbox_delivery_record_id=OUTBOX, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=date(2026, 8, 22), + confirmation_reference="human_confirmation:employment-1", + evidence_version_code="employment-evidence-v1", + idempotency_key="employment-runtime-guard", + ) + port = PostgresPeopleMutationPort(_forbidden_connection_factory) + + with pytest.raises(TypeError, match="command must be an EmploymentMutationCommand"): + port.create_employment( + command=command, + authorization=_authorization("employment_record", EMPLOYMENT), + ) + + +def test_postgres_position_port_rejects_command_subclass_before_database_work() -> None: + """Require the position persistence authority to accept only its exact command type.""" + command = ForgedPositionMutationCommand( + tenant_record_id=TENANT, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB, + position_record_id=POSITION, + position_record_version_id=POSITION_VERSION, + audit_event_record_id=AUDIT, + outbox_delivery_record_id=OUTBOX, + position_status_code="open", + effective_from=date(2026, 8, 22), + confirmation_reference="human_confirmation:position-1", + evidence_version_code="position-evidence-v1", + idempotency_key="position-runtime-guard", + ) + port = PostgresPeopleMutationPort(_forbidden_connection_factory) + + with pytest.raises(TypeError, match="command must be a PositionMutationCommand"): + port.create_position( + command=command, + authorization=_authorization("position_record", POSITION), + ) + + +def test_postgres_assignment_port_rejects_command_subclass_before_database_work() -> None: + """Require the assignment persistence authority to accept only its exact command type.""" + command = ForgedAssignmentMutationCommand( + tenant_record_id=TENANT, + employment_record_id=EMPLOYMENT, + person_record_id=PERSON, + position_record_id=POSITION, + assignment_record_id=ASSIGNMENT, + audit_event_record_id=AUDIT, + outbox_delivery_record_id=OUTBOX, + allocation_ratio=Decimal("1.0000"), + effective_from=date(2026, 8, 22), + confirmation_reference="human_confirmation:assignment-1", + evidence_version_code="assignment-evidence-v1", + idempotency_key="assignment-runtime-guard", + ) + port = PostgresPeopleMutationPort(_forbidden_connection_factory) + + with pytest.raises(TypeError, match="command must be an AssignmentMutationCommand"): + port.create_assignment( + command=command, + authorization=_authorization("assignment_record", ASSIGNMENT), + ) From 0196bf545b6254a410c99be80216ac977a706683 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:42:13 -0700 Subject: [PATCH 012/269] fix(people): require exact mutation command runtime types --- .../src/orgmetra_people_api/postgres_mutations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 1bfb9b086..63e01d086 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -534,7 +534,7 @@ def create_employment( authorization: AuthorizationDecision, ) -> EmploymentMutationResult: """Persist one employment after conversion and exclusivity checks.""" - if not isinstance(command, EmploymentMutationCommand): + if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") decision = _require_authorization( authorization=authorization, @@ -637,7 +637,7 @@ def create_position( authorization: AuthorizationDecision, ) -> PositionMutationResult: """Persist one position after organization and job parent checks.""" - if not isinstance(command, PositionMutationCommand): + if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") decision = _require_authorization( authorization=authorization, @@ -727,7 +727,7 @@ def create_assignment( authorization: AuthorizationDecision, ) -> AssignmentMutationResult: """Persist one assignment after conversion and kernel coverage checks.""" - if not isinstance(command, AssignmentMutationCommand): + if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") decision = _require_authorization( authorization=authorization, From be156d88b41e53887cdc07a26c4f9fc51591699b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:04:39 -0700 Subject: [PATCH 013/269] test(people): reject forged mutation text evidence --- ..._people_mutation_text_runtime_integrity.py | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_text_runtime_integrity.py diff --git a/services/people-api/tests/test_people_mutation_text_runtime_integrity.py b/services/people-api/tests/test_people_mutation_text_runtime_integrity.py new file mode 100644 index 000000000..83afdcb66 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_text_runtime_integrity.py @@ -0,0 +1,180 @@ +"""Reject caller-controlled text subclasses at authoritative People write boundaries.""" + +from __future__ import annotations + +from datetime import date +from uuid import UUID + +import pytest + +from orgmetra_people_api.hire import HireAcceptanceCommand +from orgmetra_people_api.mutations import EmploymentMutationCommand, PositionMutationCommand + +TENANT = UUID("0198a412-8000-7000-8000-000000000101") +PERSON = UUID("0198a412-8000-7000-8000-000000000102") +CANDIDATE = UUID("0198a412-8000-7000-8000-000000000103") +SELECTION_DECISION = UUID("0198a412-8000-7000-8000-000000000104") +EMPLOYMENT = UUID("0198a412-8000-7000-8000-000000000105") +EMPLOYMENT_VERSION = UUID("0198a412-8000-7000-8000-000000000106") +ORGANIZATION = UUID("0198a412-8000-7000-8000-000000000107") +JOB = UUID("0198a412-8000-7000-8000-000000000108") +POSITION = UUID("0198a412-8000-7000-8000-000000000109") +POSITION_VERSION = UUID("0198a412-8000-7000-8000-00000000010a") +PERSON_NAME = UUID("0198a412-8000-7000-8000-00000000010b") +CONVERSION = UUID("0198a412-8000-7000-8000-00000000010c") +AUDIT = UUID("0198a412-8000-7000-8000-00000000010d") +OUTBOX = UUID("0198a412-8000-7000-8000-00000000010e") + + +class _ForgedClosedCode(str): + """Present unsafe underlying text as the reviewed ``active`` status.""" + + def __hash__(self) -> int: + """Collide with the reviewed status during set lookup.""" + return hash("active") + + def __eq__(self, other: object) -> bool: + """Claim equality with the reviewed status while retaining unsafe text.""" + return other == "active" + + def __ne__(self, other: object) -> bool: + """Keep inequality consistent with the forged equality result.""" + return not self.__eq__(other) + + +class _ForgedConcurrencyCode(str): + """Present unsafe underlying text as the reviewed ``exclusive`` code.""" + + def __hash__(self) -> int: + """Collide with the reviewed concurrency code during set lookup.""" + return hash("exclusive") + + def __eq__(self, other: object) -> bool: + """Claim equality with the reviewed concurrency code.""" + return other == "exclusive" + + def __ne__(self, other: object) -> bool: + """Keep inequality consistent with the forged equality result.""" + return not self.__eq__(other) + + +class _ForgedIdempotencyKey(str): + """Hide an unsafe underlying key from length and character validation.""" + + def __len__(self) -> int: + """Pretend the key satisfies the governed length contract.""" + return 20 + + def __iter__(self): + """Yield only visible ASCII while retaining unsafe underlying text.""" + return iter("A" * 20) + + +class _ForgedDisplayName(str): + """Hide control-character PII from the mutable Person-name validation path.""" + + def encode(self, *args: object, **kwargs: object) -> bytes: + """Pretend the underlying text encodes as a harmless display name.""" + del args, kwargs + return b"Alice" + + def strip(self, *args: object, **kwargs: object) -> str: + """Pretend the underlying text contains usable non-whitespace content.""" + del args, kwargs + return "Alice" + + def __len__(self) -> int: + """Pretend the underlying text satisfies the bounded PII length.""" + return 5 + + def __iter__(self): + """Hide the underlying control character from character validation.""" + return iter("Alice") + + +def _employment(**overrides: object) -> EmploymentMutationCommand: + """Build one otherwise-valid high-impact employment mutation command.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "person_record_id": PERSON, + "employment_record_id": EMPLOYMENT, + "employment_record_version_id": EMPLOYMENT_VERSION, + "audit_event_record_id": AUDIT, + "outbox_delivery_record_id": OUTBOX, + "employment_status_code": "active", + "employment_concurrency_code": "exclusive", + "effective_from": date(2026, 8, 22), + "confirmation_reference": "human_confirmation:text-runtime-22", + "evidence_version_code": "decision_evidence_set:v1", + "idempotency_key": "people-text-runtime-key-22", + } + values.update(overrides) + return EmploymentMutationCommand(**values) # type: ignore[arg-type] + + +def _position(**overrides: object) -> PositionMutationCommand: + """Build one otherwise-valid position mutation command.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "organization_unit_id": ORGANIZATION, + "job_profile_id": JOB, + "position_record_id": POSITION, + "position_record_version_id": POSITION_VERSION, + "audit_event_record_id": AUDIT, + "outbox_delivery_record_id": OUTBOX, + "position_status_code": "active", + "effective_from": date(2026, 8, 22), + "confirmation_reference": "human_confirmation:text-runtime-22", + "evidence_version_code": "position_evidence:v1", + "idempotency_key": "position-text-runtime-key-22", + } + values.update(overrides) + return PositionMutationCommand(**values) # type: ignore[arg-type] + + +def _hire(**overrides: object) -> HireAcceptanceCommand: + """Build one otherwise-valid confirmed-hire command.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "candidate_profile_id": CANDIDATE, + "selection_decision_id": SELECTION_DECISION, + "person_record_id": PERSON, + "person_name_record_id": PERSON_NAME, + "employment_record_id": EMPLOYMENT, + "employment_record_version_id": EMPLOYMENT_VERSION, + "candidate_worker_conversion_record_id": CONVERSION, + "audit_event_record_id": AUDIT, + "outbox_delivery_record_id": OUTBOX, + "effective_from": date(2026, 8, 22), + "display_name": "Alice Example", + "idempotency_key": "hire-text-runtime-key-22", + "employment_status_code": "active", + } + values.update(overrides) + return HireAcceptanceCommand(**values) # type: ignore[arg-type] + + +def test_rejects_status_string_subclass_that_forges_allow_list_membership() -> None: + """Canonical employment status text must be the exact value that was reviewed.""" + with pytest.raises(ValueError, match="employment_status_code"): + _employment(employment_status_code=_ForgedClosedCode("model_decided")) + with pytest.raises(ValueError, match="position_status_code"): + _position(position_status_code=_ForgedClosedCode("model_decided")) + + +def test_rejects_concurrency_string_subclass_that_forges_allow_list_membership() -> None: + """Concurrency evidence cannot substitute caller-defined equality semantics.""" + with pytest.raises(ValueError, match="employment_concurrency_code"): + _employment(employment_concurrency_code=_ForgedConcurrencyCode("shadow_parallel")) + + +def test_rejects_idempotency_string_subclass_that_forges_scalar_validation() -> None: + """Idempotency identity must bind the exact validated visible-ASCII text.""" + with pytest.raises(ValueError, match="idempotency_key"): + _employment(idempotency_key=_ForgedIdempotencyKey("\n")) + + +def test_rejects_display_name_string_subclass_before_person_pii_persistence() -> None: + """Necessary Person-name PII cannot hide control text behind overridden methods.""" + with pytest.raises(ValueError, match="display_name"): + _hire(display_name=_ForgedDisplayName("\n")) From 1967d7b85e13d9888f28982db194fe4edf89b8d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:06:11 -0700 Subject: [PATCH 014/269] fix(people): require exact hire display-name text --- services/people-api/src/orgmetra_people_api/hire.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 407bd9870..e85a6dcc9 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -83,7 +83,7 @@ def __post_init__(self) -> None: _validate_operational_uuid(field_name, getattr(self, field_name)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") - if not isinstance(self.display_name, str): + if type(self.display_name) is not str: raise ValueError("display_name must be a string.") try: self.display_name.encode("utf-8") From 23fc4f4ddc44d2561c4e7eae7dc1c34b0929c571 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:06:50 -0700 Subject: [PATCH 015/269] fix(people): require exact governed mutation text --- .../people-api/src/orgmetra_people_api/mutations.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index bc8a6add9..40a5ecadc 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -66,7 +66,7 @@ def _validate_evidence_version(value: object) -> None: def validate_idempotency_key(value: object) -> str: """Require the same visible-ASCII Idempotency-Key contract as the HTTP boundary.""" - if not isinstance(value, str) or not (_IDEMPOTENCY_MIN <= len(value) <= _IDEMPOTENCY_MAX): + if type(value) is not str or not (_IDEMPOTENCY_MIN <= len(value) <= _IDEMPOTENCY_MAX): raise ValueError("idempotency_key must be 16 to 200 visible ASCII characters.") if any(ord(character) < 0x21 or ord(character) > 0x7E for character in value): raise ValueError("idempotency_key must be 16 to 200 visible ASCII characters.") @@ -192,10 +192,10 @@ def __post_init__(self) -> None: _validate_operational_uuid(field_name, getattr(self, field_name)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") - if not isinstance(self.employment_status_code, str) or self.employment_status_code not in _EMPLOYMENT_STATUSES: + if type(self.employment_status_code) is not str or self.employment_status_code not in _EMPLOYMENT_STATUSES: raise ValueError("employment_status_code must be active, leave, or terminated.") if ( - not isinstance(self.employment_concurrency_code, str) + type(self.employment_concurrency_code) is not str or self.employment_concurrency_code not in _CONCURRENCY_CODES ): raise ValueError("employment_concurrency_code must be exclusive or concurrent.") @@ -235,7 +235,7 @@ def __post_init__(self) -> None: _validate_operational_uuid(field_name, getattr(self, field_name)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") - if not isinstance(self.position_status_code, str) or self.position_status_code not in _POSITION_STATUSES: + if type(self.position_status_code) is not str or self.position_status_code not in _POSITION_STATUSES: raise ValueError("position_status_code must be a staffable or closed seat status.") _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) @@ -446,4 +446,4 @@ def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" if not isinstance(raw_value, str) or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) + return Decimal(raw_value) \ No newline at end of file From c397053ba62dfaf4dd84d6b3d581fccb756e55bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 19:01:55 +0900 Subject: [PATCH 016/269] fix(people): close remaining governed text gaps --- CHANGELOG.md | 1 + manifest.json | 2 +- .../src/orgmetra_people_api/hire.py | 2 +- .../src/orgmetra_people_api/mutations.py | 6 +++--- ..._people_mutation_text_runtime_integrity.py | 20 +++++++++++++++++++ 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f4752d7..8646658c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ All notable changes to Orgmetra will be documented in this file. - Made assignment coverage status-aware: `active` and `leave` remain staffable while `terminated` and other non-eligible employment statuses fail closed. - Made organization hierarchy reconstruction fail closed on a cycle at the requested tenant, effective day, and knowledge cutoff while ignoring future-recorded and foreign-tenant facts. - Build the outbox due-work index concurrently during migration 0008, requiring that index step to run outside an explicit transaction block so established queues do not block writers while the index is built; pre-index hardening and post-index privileged role setup use separate explicit transactions. +- Active-PR People mutation commands now require exact built-in governance text and hire status values before digesting or persisting high-impact employment evidence. ### Security diff --git a/manifest.json b/manifest.json index 97f2bab14..7234d93d9 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":"d5e14a326a99cc450114c3502a1adf0b4515667005de20f276eaefb0093b5715","bytes":17462,"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":"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}]} diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index e85a6dcc9..0d8bbbd7e 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -95,7 +95,7 @@ def __post_init__(self) -> None: raise ValueError("display_name must not contain control characters.") validate_idempotency_key(self.idempotency_key) if ( - not isinstance(self.employment_status_code, str) + type(self.employment_status_code) is not str or _STATUS_CODE_PATTERN.fullmatch(self.employment_status_code) is None ): raise ValueError("employment_status_code must be a lower snake_case code.") diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 40a5ecadc..ada1c59aa 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -54,13 +54,13 @@ def _validate_operational_uuid(field_name: str, value: object) -> None: def _validate_confirmation(value: object) -> None: """Require one namespaced human-confirmation reference.""" - if not isinstance(value, str) or _REFERENCE_PATTERN.fullmatch(value) is None: + if type(value) is not str or _REFERENCE_PATTERN.fullmatch(value) is None: raise ValueError("confirmation_reference must be a namespaced opaque reference.") def _validate_evidence_version(value: object) -> None: """Require one whitespace-free evidence version token.""" - if not isinstance(value, str) or _VERSION_PATTERN.fullmatch(value) is None: + if type(value) is not str or _VERSION_PATTERN.fullmatch(value) is None: raise ValueError("evidence_version_code must be a whitespace-free version token.") @@ -446,4 +446,4 @@ def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" if not isinstance(raw_value, str) or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) \ No newline at end of file + return Decimal(raw_value) diff --git a/services/people-api/tests/test_people_mutation_text_runtime_integrity.py b/services/people-api/tests/test_people_mutation_text_runtime_integrity.py index 83afdcb66..2b0bad992 100644 --- a/services/people-api/tests/test_people_mutation_text_runtime_integrity.py +++ b/services/people-api/tests/test_people_mutation_text_runtime_integrity.py @@ -92,6 +92,14 @@ def __iter__(self): return iter("Alice") +class _ForgedGovernanceText(str): + """Present reviewed governance text with caller-defined rendering semantics.""" + + def __str__(self) -> str: + """Render a different value if canonical evidence later formats the field.""" + return "caller_defined_governance_text" + + def _employment(**overrides: object) -> EmploymentMutationCommand: """Build one otherwise-valid high-impact employment mutation command.""" values: dict[str, object] = { @@ -160,6 +168,8 @@ def test_rejects_status_string_subclass_that_forges_allow_list_membership() -> N _employment(employment_status_code=_ForgedClosedCode("model_decided")) with pytest.raises(ValueError, match="position_status_code"): _position(position_status_code=_ForgedClosedCode("model_decided")) + with pytest.raises(ValueError, match="employment_status_code"): + _hire(employment_status_code=_ForgedClosedCode("model_decided")) def test_rejects_concurrency_string_subclass_that_forges_allow_list_membership() -> None: @@ -178,3 +188,13 @@ def test_rejects_display_name_string_subclass_before_person_pii_persistence() -> """Necessary Person-name PII cannot hide control text behind overridden methods.""" with pytest.raises(ValueError, match="display_name"): _hire(display_name=_ForgedDisplayName("\n")) + + +def test_rejects_governance_text_subclasses_before_digest_or_persistence() -> None: + """Confirmation and evidence text must retain the exact reviewed runtime value.""" + with pytest.raises(ValueError, match="confirmation_reference"): + _employment( + confirmation_reference=_ForgedGovernanceText("human_confirmation:text-runtime-22") + ) + with pytest.raises(ValueError, match="evidence_version_code"): + _position(evidence_version_code=_ForgedGovernanceText("position_evidence:v1")) From 399010c5ce2480e13ee43c4f9ff233f96d23c247 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:21:32 +0900 Subject: [PATCH 017/269] merge(people): preserve #161 changelog delta after restack --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8646658c1..98d5b3e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to Orgmetra will be documented in this file. - `employment_record_version.employment_concurrency_code` constrained to `exclusive` or `concurrent`. - ADR 0005 for exclusive employment and staffable seats. - `orgmetra_hris_kernel` 0.3.0 with identity-scoped bitemporal resolution, assignment-employment coverage, allocation-portfolio checks, and a Memorial Hospital RN correction case at 100% statement and branch coverage. -- `employment_record_version` and `position_record_version` so employment and position identity stay stable across retroactive corrections. +- `employment_record_version` and `position_record_version` so corrections no longer mint a new employment or position identifier. - `assignment_record.employment_record_id` bound to the same person as the covering employment. - `orgmetra_keyverse_adapter` that binds an opaque Keyverse subject to a person and rejects passwords, passkeys, and tokens. - Design tokens for the repeating HR actions: approve, review, correct, request evidence, compare, export, and escalate. @@ -37,6 +37,7 @@ All notable changes to Orgmetra will be documented in this file. ### Changed +- Consolidated repository-owned PR validation from twelve workflows into one Foundation CI job, while keeping the dual-cluster recovery rehearsal separately path-scoped. Central required review and security workflows remain organization-owned. - New predictive-validity membership must use one normalized worker-level case; the three independent validity-study decision/evidence/outcome link relations are historical read surfaces only and can no longer accept new rows. A case insert also rejects a criterion observation whose recorded interval is already closed at `linked_at`. - Canonicalized service identifiers as two-or-more-word `snake_case` across architecture, deployment, ACL, metrics, and client contracts. - Separated fast-mlsirm, TEPP, and Psychometrics Commons into immutable external scientific contracts. From 19685924a16d7b7bae80f23d55fde04c6a3acd27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 23:23:52 +0900 Subject: [PATCH 018/269] fix(ci): reseal People manifest after protected restack --- manifest.json | 476 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 475 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 7234d93d9..956e51d26 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1,475 @@ -{"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":"d5e14a326a99cc450114c3502a1adf0b4515667005de20f276eaefb0093b5715","bytes":17462,"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":"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": "b6a4365936b66803a8112f034c77d53d33301a7a798ed4f68746a4f2d8b081d7", + "bytes": 6651, + "lines": 125 + }, + { + "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": "9ad6dad273c94c30741522ca87205ff24eb92c53becc8b53739d93acb28126f9", + "bytes": 17697, + "lines": 78 + }, + { + "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": "dbe96dfd47066288cec835789de54cc4293f920d2ad4b0e0dba930191d7d249b", + "bytes": 5551, + "lines": 53 + }, + { + "path": "docs/adr/0012-governed-migration-handoff.md", + "sha256": "c7bfbda34996f717ed31f8307acc16a5d69ae464edb184ab5c8ec4b2d5763cbc", + "bytes": 5958, + "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": "9b03efbbdffa60a05f5924e8a61b1cbc3cd75c502df428a5920085e8d0bf3603", + "bytes": 28121, + "lines": 688 + }, + { + "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": "648533b4aff8cee643df4afc06b463eda788e002e11d043971c8a16804c68501", + "bytes": 14943, + "lines": 387 + }, + { + "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": "091836b2f68600a30b08f7da2cea8b3bef10201a123da720a7369bf10985eec2", + "bytes": 27237, + "lines": 637 + } + ] +} From cde8df27fcffc0b4abee57178085ee0f2153c571 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:30:07 +0900 Subject: [PATCH 019/269] test(people): reject executable hire decision timestamps --- ...stgres_hire_timestamp_runtime_integrity.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py new file mode 100644 index 000000000..539649261 --- /dev/null +++ b/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py @@ -0,0 +1,52 @@ +"""Runtime-integrity contracts for durable hire-decision timestamps.""" + +from datetime import datetime, timedelta, timezone, tzinfo +from zoneinfo import ZoneInfo + +from orgmetra_people_api.postgres_hire import _is_aware_datetime + + +class _ExecutableTimezone(tzinfo): + """Record forbidden offset resolution at the People durable boundary.""" + + def __init__(self) -> None: + self.calls = 0 + + def utcoffset(self, dt: datetime | None) -> timedelta: + """Fail if validation executes caller-defined timezone behavior.""" + del dt + self.calls += 1 + raise AssertionError("caller-defined timezone callback executed") + + +class _ExecutableDatetime(datetime): + """Fail if validation executes behavior from a datetime subtype.""" + + def utcoffset(self) -> timedelta: + """Expose subtype execution if the exact-type gate is missing.""" + raise AssertionError("datetime subtype callback executed") + + +def test_hire_timestamp_rejects_custom_timezone_before_callback() -> None: + """Exact datetime values cannot delegate offset validation to caller code.""" + provider = _ExecutableTimezone() + value = datetime(2026, 8, 18, 0, 0, tzinfo=provider) + + assert _is_aware_datetime(value) is False + assert provider.calls == 0 + + +def test_hire_timestamp_rejects_datetime_subtype_before_callback() -> None: + """Executable datetime subtypes are not durable selection-decision evidence.""" + value = _ExecutableDatetime(2026, 8, 18, 0, 0, tzinfo=timezone.utc) + + assert _is_aware_datetime(value) is False + + +def test_hire_timestamp_accepts_exact_standard_library_timezones() -> None: + """Psycopg-compatible standard-library timezone materialization stays valid.""" + utc_value = datetime(2026, 8, 18, 0, 0, tzinfo=timezone.utc) + seoul_value = datetime(2026, 8, 18, 9, 0, tzinfo=ZoneInfo("Asia/Seoul")) + + assert _is_aware_datetime(utc_value) is True + assert _is_aware_datetime(seoul_value) is True From 98882232244920f919c0fdaf9b7a2c6ee67879c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:31:02 +0900 Subject: [PATCH 020/269] fix(people): exact-gate hire decision time providers --- .../src/orgmetra_people_api/postgres_hire.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 705caefe4..2378f67de 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -12,12 +12,13 @@ from contextlib import AbstractContextManager from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from hashlib import sha256 import json import re from typing import Any, Callable from uuid import UUID +from zoneinfo import ZoneInfo from orgmetra_hris_kernel.audit import AuditOutboxEvent from orgmetra_keyverse_adapter import AuthorizationDecision @@ -100,7 +101,7 @@ tenant_record_id, person_record_id, recorded_from -) VALUES (%s, %s, %s) +) VALUES (%s, %s, %s, %s) """.strip() _INSERT_PERSON_NAME_SQL = """ @@ -169,8 +170,12 @@ def _is_operational_uuid(value: object) -> bool: def _is_aware_datetime(value: object) -> bool: - """Return whether a value is a timezone-aware datetime with a real offset.""" - return isinstance(value, datetime) and value.tzinfo is not None and value.utcoffset() is not None + """Return whether durable time is exact and backed by an inert standard provider.""" + if type(value) is not datetime or value.tzinfo is None: + return False + if type(value.tzinfo) not in (timezone, ZoneInfo): + return False + return value.utcoffset() is not None def _validate_authorization(command: HireAcceptanceCommand, authorization: object) -> AuthorizationDecision: @@ -396,7 +401,6 @@ def accept_hire( ( command.tenant_record_id, command.person_name_record_id, - command.person_record_id, command.display_name, command.effective_from, transaction_recorded_at, From 61cba0fe82107c07b979a42e57f6cd64e75cdf8a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:32:27 +0900 Subject: [PATCH 021/269] fix(people): restore hire insert contract after timestamp repair --- services/people-api/src/orgmetra_people_api/postgres_hire.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 2378f67de..e183cd93f 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -101,7 +101,7 @@ tenant_record_id, person_record_id, recorded_from -) VALUES (%s, %s, %s, %s) +) VALUES (%s, %s, %s) """.strip() _INSERT_PERSON_NAME_SQL = """ @@ -401,6 +401,7 @@ def accept_hire( ( command.tenant_record_id, command.person_name_record_id, + command.person_record_id, command.display_name, command.effective_from, transaction_recorded_at, From 42d988a7293c083f0d2160e770b4b1cedeb18d52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 03:37:07 +0900 Subject: [PATCH 022/269] test(people): document executable timezone tripwire --- .../tests/test_postgres_hire_timestamp_runtime_integrity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py index 539649261..15f62ff86 100644 --- a/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py @@ -10,6 +10,7 @@ class _ExecutableTimezone(tzinfo): """Record forbidden offset resolution at the People durable boundary.""" def __init__(self) -> None: + """Initialize the callback counter without resolving an offset.""" self.calls = 0 def utcoffset(self, dt: datetime | None) -> timedelta: From 792dfe37ca2ad99f8cfbca8ce688c8555bc62817 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:00:42 +0900 Subject: [PATCH 023/269] test(people): reject executable durable hire UUID evidence --- ...st_postgres_hire_uuid_runtime_integrity.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py new file mode 100644 index 000000000..7b12d98ad --- /dev/null +++ b/services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py @@ -0,0 +1,32 @@ +"""Runtime-integrity contracts for durable hire UUID evidence.""" + +from uuid import UUID + +from orgmetra_people_api.postgres_hire import _is_operational_uuid + + +_MAX_UUID_INT = (1 << 128) - 1 + + +class _ExecutableUUID(UUID): + """Expose any UUID attribute inspection performed before an exact-type gate.""" + + def __getattribute__(self, name: str) -> object: + """Fail when untrusted UUID evidence is inspected as if it were inert.""" + if name == "int": + raise AssertionError("UUID subtype behavior executed before exact-type validation") + return super().__getattribute__(name) + + +def test_hire_durable_uuid_rejects_subtype_before_identity_inspection() -> None: + """Database-returned UUID subtypes must fail without executing subtype behavior.""" + value = _ExecutableUUID("0198a412-7100-7000-8000-000000000060") + + assert _is_operational_uuid(value) is False + + +def test_hire_durable_uuid_accepts_only_operational_exact_uuid_values() -> None: + """Exact Psycopg-compatible UUID values remain valid except reserved sentinels.""" + assert _is_operational_uuid(UUID("0198a412-7100-7000-8000-000000000060")) is True + assert _is_operational_uuid(UUID(int=0)) is False + assert _is_operational_uuid(UUID(int=_MAX_UUID_INT)) is False From 6443ee54fd69e4bb3c0cf7e6d211087f87f91655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:02:05 +0900 Subject: [PATCH 024/269] fix(people): exact-gate durable hire UUID evidence --- services/people-api/src/orgmetra_people_api/postgres_hire.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index e183cd93f..07fefb41d 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -166,7 +166,7 @@ def _is_operational_uuid(value: object) -> bool: """Return whether a value is an Orgmetra operational UUID.""" - return isinstance(value, UUID) and value.int not in (0, _MAX_UUID_INT) + return type(value) is UUID and value.int not in (0, _MAX_UUID_INT) def _is_aware_datetime(value: object) -> bool: @@ -458,4 +458,4 @@ def accept_hire( person_record_id=command.person_record_id, employment_record_id=command.employment_record_id, candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, - ) \ No newline at end of file + ) From 2ac5a1165c1fce2cf9c500dd9c41c94dd39e5d67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:03:44 +0900 Subject: [PATCH 025/269] test(people): reject executable hire idempotency digest text --- ...hire_idempotency_text_runtime_integrity.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 services/people-api/tests/test_postgres_hire_idempotency_text_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_hire_idempotency_text_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_idempotency_text_runtime_integrity.py new file mode 100644 index 000000000..8b3d73cbf --- /dev/null +++ b/services/people-api/tests/test_postgres_hire_idempotency_text_runtime_integrity.py @@ -0,0 +1,119 @@ +"""Runtime-integrity contracts for durable hire idempotency digest text.""" + +from __future__ import annotations + +from datetime import date +from typing import Any +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.hire import HireAcceptanceCommand, HireDecisionIntegrityError +from orgmetra_people_api.postgres_hire import _hire_command_digest, _replayed_hire + +TENANT = UUID("0198a412-7100-7000-8000-000000000001") +CANDIDATE = UUID("0198a412-7100-7000-8000-000000000010") +DECISION = UUID("0198a412-7100-7000-8000-000000000011") +PERSON = UUID("0198a412-7100-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-7100-7000-8000-000000000021") +EMPLOYMENT = UUID("0198a412-7100-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-7100-7000-8000-000000000031") +CONVERSION = UUID("0198a412-7100-7000-8000-000000000040") +AUDIT_EVENT = UUID("0198a412-7100-7000-8000-000000000050") +OUTBOX_DELIVERY = UUID("0198a412-7100-7000-8000-000000000051") +ACTOR = "keyverse_subject:operator-17" +PURPOSE = "candidate_hire" + + +class _ExecutableText(str): + """Expose comparison performed before exact durable-text validation.""" + + def __eq__(self, other: object) -> bool: + """Fail if untrusted text participates in trusted equality.""" + del other + raise AssertionError("text subtype equality executed before exact-type validation") + + def __ne__(self, other: object) -> bool: + """Fail if untrusted text participates in trusted inequality.""" + del other + raise AssertionError("text subtype inequality executed before exact-type validation") + + +class _ReplayCursor: + """Return one durable idempotency row without touching a real database.""" + + def __init__(self, row: tuple[object, object]) -> None: + """Store the single replay row returned after advisory serialization.""" + self._row = row + + def execute(self, statement: str, parameters: tuple[object, ...]) -> None: + """Accept the two read-side SQL calls used by replay resolution.""" + assert statement + assert parameters + + def fetchmany(self, size: int) -> list[tuple[object, object]]: + """Return the configured row using the adapter's bounded read size.""" + assert size == 2 + return [self._row] + + +def _command() -> HireAcceptanceCommand: + """Build one deterministic valid confirmed-hire command.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX_DELIVERY, + effective_from=date(2026, 8, 18), + display_name="Ada Lovelace", + idempotency_key="hire-idempotency-text-runtime-integrity", + ) + + +def _authorization() -> AuthorizationDecision: + """Build the exact allow decision required for the confirmed-hire command.""" + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference=ACTOR, + resource_reference=f"selection_decision:{DECISION.hex}", + policy_version_code="people-hire-v1", + purpose_code=PURPOSE, + operation_code="materialize_worker", + resource_kind="selection_decision", + requested_fields=frozenset({"candidate_worker_conversion"}), + authorized_fields=frozenset({"candidate_worker_conversion"}), + reason_code="access_permitted", + next_action="continue", + ) + + +def test_hire_replay_rejects_digest_subtype_before_comparison() -> None: + """Database-returned digest subtypes must fail without executing comparison hooks.""" + command = _command() + authorization = _authorization() + digest = _ExecutableText(_hire_command_digest(command, authorization)) + cursor: Any = _ReplayCursor((CONVERSION, digest)) + + with pytest.raises(HireDecisionIntegrityError, match="hire idempotency row is invalid"): + _replayed_hire(cursor, command=command, authorization=authorization) + + +def test_hire_replay_accepts_exact_builtin_digest_text() -> None: + """An exact persisted digest still replays the exact committed conversion.""" + command = _command() + authorization = _authorization() + digest = _hire_command_digest(command, authorization) + cursor: Any = _ReplayCursor((CONVERSION, digest)) + + result = _replayed_hire(cursor, command=command, authorization=authorization) + + assert result is not None + assert result.candidate_worker_conversion_record_id == CONVERSION From d81260f9fb6ce5be95c8346e468b78cd59742ff8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:08:33 +0900 Subject: [PATCH 026/269] fix(people): exact-gate durable hire idempotency digest text --- services/people-api/src/orgmetra_people_api/postgres_hire.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 07fefb41d..95086ac21 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -238,7 +238,7 @@ def _replayed_hire( if len(rows) != 1 or len(rows[0]) != 2: raise HireDecisionIntegrityError("hire idempotency row is invalid") created_record_id, stored_digest = rows[0] - if not _is_operational_uuid(created_record_id) or not isinstance(stored_digest, str): + if not _is_operational_uuid(created_record_id) or type(stored_digest) is not str: raise HireDecisionIntegrityError("hire idempotency row is invalid") if stored_digest != _hire_command_digest(command, authorization): raise HireDecisionIntegrityError("hire idempotency key is bound to a different command") From 878e98be00ef71a7e6529503ff560342556d3593 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:11:19 +0900 Subject: [PATCH 027/269] test(people): reject executable hire provenance text --- ..._hire_provenance_text_runtime_integrity.py | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 services/people-api/tests/test_postgres_hire_provenance_text_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_hire_provenance_text_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_provenance_text_runtime_integrity.py new file mode 100644 index 000000000..ae9e57718 --- /dev/null +++ b/services/people-api/tests/test_postgres_hire_provenance_text_runtime_integrity.py @@ -0,0 +1,181 @@ +"""Runtime-integrity contracts for durable hire decision-provenance text.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.hire import HireAcceptanceCommand, HireDecisionIntegrityError +from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort + +TENANT = UUID("0198a412-7100-7000-8000-000000000001") +CANDIDATE = UUID("0198a412-7100-7000-8000-000000000010") +DECISION = UUID("0198a412-7100-7000-8000-000000000011") +PERSON = UUID("0198a412-7100-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-7100-7000-8000-000000000021") +EMPLOYMENT = UUID("0198a412-7100-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-7100-7000-8000-000000000031") +CONVERSION = UUID("0198a412-7100-7000-8000-000000000040") +AUDIT_EVENT = UUID("0198a412-7100-7000-8000-000000000050") +OUTBOX_DELIVERY = UUID("0198a412-7100-7000-8000-000000000051") +EVIDENCE_SET = UUID("0198a412-7100-7000-8000-000000000060") +DECIDED_AT = datetime(2026, 8, 18, 0, 0, tzinfo=timezone.utc) +TRANSACTION_AT = datetime(2026, 8, 18, 0, 1, tzinfo=timezone.utc) +ACTOR = "keyverse_subject:operator-17" +PURPOSE = "candidate_hire" +CONFIRMATION = "human_confirmation:review-88" + + +class _ExecutableText(str): + """Expose comparison performed before exact durable-text validation.""" + + def __eq__(self, other: object) -> bool: + """Fail if durable-row validation executes subtype equality.""" + del other + raise AssertionError("provenance text equality executed before exact-type validation") + + def __ne__(self, other: object) -> bool: + """Fail if durable-row validation executes subtype inequality.""" + del other + raise AssertionError("provenance text inequality executed before exact-type validation") + + +class _Cursor: + """Serve one idempotency miss followed by one decision-provenance row.""" + + def __init__(self, decision_row: tuple[object, ...]) -> None: + """Store the row and initialize transaction-observation state.""" + self._batches = [[], [decision_row]] + self.executions: list[str] = [] + + def __enter__(self) -> _Cursor: + """Return the same cursor for the transaction context.""" + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Leave exception handling to the connection context.""" + del exc_type, exc_value, traceback + + def execute(self, statement: str, parameters: tuple[object, ...] | None = None) -> None: + """Record SQL without evaluating trust-bearing row values.""" + del parameters + self.executions.append(statement) + + def fetchmany(self, size: int) -> list[tuple[object, ...]]: + """Return each bounded batch in adapter execution order.""" + assert size == 2 + return self._batches.pop(0)[:size] + + +class _Connection: + """Provide the focused cursor through a DB-API-style context boundary.""" + + def __init__(self, cursor: _Cursor) -> None: + """Retain the one cursor used by the focused durable-row test.""" + self._cursor = cursor + + def __enter__(self) -> _Connection: + """Return the same transaction connection.""" + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Leave exception propagation unchanged.""" + del exc_type, exc_value, traceback + + def cursor(self) -> _Cursor: + """Return the configured focused cursor.""" + return self._cursor + + +def _command() -> HireAcceptanceCommand: + """Build one deterministic valid confirmed-hire command.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX_DELIVERY, + effective_from=date(2026, 8, 18), + display_name="Ada Lovelace", + idempotency_key="hire-provenance-text-runtime-integrity", + ) + + +def _authorization() -> AuthorizationDecision: + """Build the exact allow decision required for the confirmed-hire command.""" + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference=ACTOR, + resource_reference=f"selection_decision:{DECISION.hex}", + policy_version_code="people-hire-v1", + purpose_code=PURPOSE, + operation_code="materialize_worker", + resource_kind="selection_decision", + requested_fields=frozenset({"candidate_worker_conversion"}), + authorized_fields=frozenset({"candidate_worker_conversion"}), + reason_code="access_permitted", + next_action="continue", + ) + + +def _decision_row(**overrides: object) -> tuple[object, ...]: + """Build one durable confirmed-hire provenance row in SQL column order.""" + values: dict[str, object] = { + "actor_reference": ACTOR, + "purpose_code": PURPOSE, + "decision_code": "hire", + "confirmation_reference": CONFIRMATION, + "decided_at": DECIDED_AT, + "decision_evidence_set_id": EVIDENCE_SET, + "transaction_recorded_at": TRANSACTION_AT, + } + values.update(overrides) + return ( + values["actor_reference"], + values["purpose_code"], + values["decision_code"], + values["confirmation_reference"], + values["decided_at"], + values["decision_evidence_set_id"], + values["transaction_recorded_at"], + ) + + +@pytest.mark.parametrize( + "field,value", + ( + ("actor_reference", _ExecutableText(ACTOR)), + ("purpose_code", _ExecutableText(PURPOSE)), + ("decision_code", _ExecutableText("hire")), + ("confirmation_reference", _ExecutableText(CONFIRMATION)), + ), +) +def test_hire_rejects_provenance_text_subtype_before_business_write(field: str, value: object) -> None: + """Database provenance text must be exact built-in text before semantic use.""" + cursor = _Cursor(_decision_row(**{field: value})) + port = PostgresHireAcceptancePort(lambda: _Connection(cursor)) + + with pytest.raises(HireDecisionIntegrityError, match="selection decision provenance text is invalid"): + port.accept_hire(command=_command(), authorization=_authorization()) + + assert not any("INSERT INTO public.person_record" in statement for statement in cursor.executions) + + +def test_hire_accepts_exact_builtin_provenance_text() -> None: + """Exact Psycopg-compatible text remains valid durable decision provenance.""" + cursor = _Cursor(_decision_row()) + port = PostgresHireAcceptancePort(lambda: _Connection(cursor)) + + result = port.accept_hire(command=_command(), authorization=_authorization()) + + assert result.candidate_worker_conversion_record_id == CONVERSION + assert any("INSERT INTO public.person_record" in statement for statement in cursor.executions) From c260b05ee1821db427473b1420c7e7098a9da93f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:13:14 +0900 Subject: [PATCH 028/269] fix(people): exact-gate durable hire provenance text --- .../src/orgmetra_people_api/postgres_hire.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 95086ac21..46c359d9b 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -346,16 +346,23 @@ def accept_hire( transaction_recorded_at, ) = row + if any( + type(value) is not str + for value in ( + decision_actor_reference, + decision_purpose_code, + decision_code, + confirmation_reference, + ) + ): + raise HireDecisionIntegrityError("selection decision provenance text is invalid") if decision_code != "hire": raise HireDecisionIntegrityError("selection decision is not an explicit hire") if decision_actor_reference != decision.actor_reference: raise HireDecisionIntegrityError("selection decision actor does not match authorized actor") if decision_purpose_code != decision.purpose_code: raise HireDecisionIntegrityError("selection decision purpose does not match authorized purpose") - if ( - not isinstance(confirmation_reference, str) - or _REFERENCE_PATTERN.fullmatch(confirmation_reference) is None - ): + if _REFERENCE_PATTERN.fullmatch(confirmation_reference) is None: raise HireDecisionIntegrityError("selection decision lacks valid human confirmation") if not _is_operational_uuid(evidence_set_id): raise HireDecisionIntegrityError("selection decision evidence set identity is invalid") From 5514b745233da940dd730d2c9dfdb8aaf8630e99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:32:41 +0900 Subject: [PATCH 029/269] test(people): reject executable durable hire row containers --- ...es_hire_row_container_runtime_integrity.py | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py new file mode 100644 index 000000000..c084f9c48 --- /dev/null +++ b/services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py @@ -0,0 +1,231 @@ +"""Runtime-integrity contracts for durable hire row containers.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.hire import HireAcceptanceCommand, HireDecisionIntegrityError +from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort + +TENANT = UUID("0198a412-7100-7000-8000-000000000001") +CANDIDATE = UUID("0198a412-7100-7000-8000-000000000010") +DECISION = UUID("0198a412-7100-7000-8000-000000000011") +PERSON = UUID("0198a412-7100-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-7100-7000-8000-000000000021") +EMPLOYMENT = UUID("0198a412-7100-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-7100-7000-8000-000000000031") +CONVERSION = UUID("0198a412-7100-7000-8000-000000000040") +AUDIT_EVENT = UUID("0198a412-7100-7000-8000-000000000050") +OUTBOX_DELIVERY = UUID("0198a412-7100-7000-8000-000000000051") +EVIDENCE_SET = UUID("0198a412-7100-7000-8000-000000000060") +DECIDED_AT = datetime(2026, 8, 18, 0, 0, tzinfo=timezone.utc) +TRANSACTION_AT = datetime(2026, 8, 18, 0, 1, tzinfo=timezone.utc) +ACTOR = "keyverse_subject:operator-17" +PURPOSE = "candidate_hire" +CONFIRMATION = "human_confirmation:review-88" + + +class _ExecutableBatch(list[object]): + """Fail if a fetched row collection is consumed before exact-type validation.""" + + def __bool__(self) -> bool: + """Reject pre-gate truthiness.""" + raise AssertionError("row collection truthiness executed before exact-type validation") + + def __len__(self) -> int: + """Reject pre-gate length inspection.""" + raise AssertionError("row collection length executed before exact-type validation") + + def __getitem__(self, key: object) -> object: + """Reject pre-gate indexed access.""" + del key + raise AssertionError("row collection indexing executed before exact-type validation") + + def __iter__(self): + """Reject pre-gate row iteration.""" + raise AssertionError("row collection iteration executed before exact-type validation") + + +class _ExecutableRow(tuple): + """Fail if a fetched fixed row is consumed before exact-type validation.""" + + def __len__(self) -> int: + """Reject pre-gate row length inspection.""" + raise AssertionError("row length executed before exact-type validation") + + def __getitem__(self, key: object) -> object: + """Reject pre-gate row indexing.""" + del key + raise AssertionError("row indexing executed before exact-type validation") + + def __iter__(self): + """Reject pre-gate row iteration.""" + raise AssertionError("row iteration executed before exact-type validation") + + +class _Cursor: + """Serve configured bounded fetch batches and record executed SQL.""" + + def __init__(self, batches: list[object]) -> None: + """Store the exact fetch results in adapter execution order.""" + self._batches = list(batches) + self.executions: list[str] = [] + + def __enter__(self) -> _Cursor: + """Return the same cursor for the transaction context.""" + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Leave exception handling to the connection context.""" + del exc_type, exc_value, traceback + + def execute(self, statement: str, parameters: tuple[object, ...] | None = None) -> None: + """Record SQL without evaluating durable-row contents.""" + del parameters + self.executions.append(statement) + + def fetchmany(self, size: int) -> object: + """Return the next configured batch without touching its runtime hooks.""" + assert size == 2 + return self._batches.pop(0) + + +class _Connection: + """Provide the focused cursor through a DB-API-style context boundary.""" + + def __init__(self, cursor: _Cursor) -> None: + """Retain the one cursor used by the focused durable-row tests.""" + self._cursor = cursor + + def __enter__(self) -> _Connection: + """Return the same transaction connection.""" + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Leave exception propagation unchanged.""" + del exc_type, exc_value, traceback + + def cursor(self) -> _Cursor: + """Return the configured focused cursor.""" + return self._cursor + + +def _command() -> HireAcceptanceCommand: + """Build one deterministic valid confirmed-hire command.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX_DELIVERY, + effective_from=date(2026, 8, 18), + display_name="Ada Lovelace", + idempotency_key="hire-row-container-runtime-integrity", + ) + + +def _authorization() -> AuthorizationDecision: + """Build the exact allow decision required for the confirmed-hire command.""" + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference=ACTOR, + resource_reference=f"selection_decision:{DECISION.hex}", + policy_version_code="people-hire-v1", + purpose_code=PURPOSE, + operation_code="materialize_worker", + resource_kind="selection_decision", + requested_fields=frozenset({"candidate_worker_conversion"}), + authorized_fields=frozenset({"candidate_worker_conversion"}), + reason_code="access_permitted", + next_action="continue", + ) + + +def _decision_row() -> tuple[object, ...]: + """Build one valid durable confirmed-hire provenance row.""" + return ( + ACTOR, + PURPOSE, + "hire", + CONFIRMATION, + DECIDED_AT, + EVIDENCE_SET, + TRANSACTION_AT, + ) + + +def _port(*batches: object) -> tuple[PostgresHireAcceptancePort, _Cursor]: + """Build a port whose cursor returns the supplied bounded batches.""" + cursor = _Cursor(list(batches)) + return PostgresHireAcceptancePort(lambda: _Connection(cursor)), cursor + + +def test_hire_rejects_executable_idempotency_batch_before_collection_hooks() -> None: + """Replay lookup must reject a batch subtype before truthiness or iteration.""" + port, cursor = _port(_ExecutableBatch()) + + with pytest.raises(HireDecisionIntegrityError, match="hire idempotency row is invalid"): + port.accept_hire(command=_command(), authorization=_authorization()) + + assert not any("selection_decision AS decision" in statement for statement in cursor.executions) + + +def test_hire_rejects_executable_idempotency_row_before_row_hooks() -> None: + """Replay lookup must reject a row subtype before length or unpacking.""" + port, cursor = _port([_ExecutableRow((CONVERSION, "digest"))]) + + with pytest.raises(HireDecisionIntegrityError, match="hire idempotency row is invalid"): + port.accept_hire(command=_command(), authorization=_authorization()) + + assert not any("selection_decision AS decision" in statement for statement in cursor.executions) + + +def test_hire_rejects_executable_provenance_batch_before_collection_hooks() -> None: + """Decision lookup must reject a batch subtype before truthiness or iteration.""" + port, cursor = _port([], _ExecutableBatch([_decision_row()])) + + with pytest.raises(HireDecisionIntegrityError, match="decision provenance row has an invalid shape"): + port.accept_hire(command=_command(), authorization=_authorization()) + + assert not any("INSERT INTO public.person_record" in statement for statement in cursor.executions) + + +def test_hire_rejects_executable_provenance_row_before_row_hooks() -> None: + """Decision lookup must reject a row subtype before length or unpacking.""" + port, cursor = _port([], [_ExecutableRow(_decision_row())]) + + with pytest.raises(HireDecisionIntegrityError, match="decision provenance row has an invalid shape"): + port.accept_hire(command=_command(), authorization=_authorization()) + + assert not any("INSERT INTO public.person_record" in statement for statement in cursor.executions) + + +def test_hire_rejects_wrong_width_exact_rows_at_the_container_boundary() -> None: + """Exact built-in rows still require their fixed SQL projection width.""" + replay_port, _ = _port([(CONVERSION,)]) + with pytest.raises(HireDecisionIntegrityError, match="hire idempotency row is invalid"): + replay_port.accept_hire(command=_command(), authorization=_authorization()) + + provenance_port, _ = _port([], [(_decision_row()[0],)]) + with pytest.raises(HireDecisionIntegrityError, match="decision provenance row has an invalid shape"): + provenance_port.accept_hire(command=_command(), authorization=_authorization()) + + +def test_hire_accepts_exact_builtin_batches_and_rows() -> None: + """Default Psycopg-compatible list batches and tuple rows remain accepted.""" + port, cursor = _port([], [_decision_row()]) + + result = port.accept_hire(command=_command(), authorization=_authorization()) + + assert result.candidate_worker_conversion_record_id == CONVERSION + assert any("INSERT INTO public.person_record" in statement for statement in cursor.executions) From adab34478eb8fbaed570b8a620739baa48e6c2f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 05:34:20 +0900 Subject: [PATCH 030/269] fix(people): exact-gate durable hire row containers --- .../src/orgmetra_people_api/postgres_hire.py | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 46c359d9b..4bffbdb2d 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -178,6 +178,18 @@ def _is_aware_datetime(value: object) -> bool: return value.utcoffset() is not None +def _unpack_fixed_rows(value: object, *, row_width: int, error_message: str) -> tuple[tuple[object, ...], ...]: + """Detach one bounded fixed projection only from inert built-in containers.""" + if type(value) not in (list, tuple): + raise HireDecisionIntegrityError(error_message) + rows: list[tuple[object, ...]] = [] + for row in value: + if type(row) not in (list, tuple) or len(row) != row_width: + raise HireDecisionIntegrityError(error_message) + rows.append(tuple(row)) + return tuple(rows) + + def _validate_authorization(command: HireAcceptanceCommand, authorization: object) -> AuthorizationDecision: """Require an exact allow decision for this immutable selection decision.""" expected_reference = f"selection_decision:{command.selection_decision_id.hex}" @@ -232,10 +244,14 @@ def _replayed_hire( key_parameters = (command.tenant_record_id, _HIRE_IDEMPOTENCY_ROUTE, command.idempotency_key) cursor.execute(_LOOKUP_HIRE_IDEMPOTENCY_SQL, key_parameters) cursor.execute(_READ_HIRE_IDEMPOTENCY_SQL, key_parameters) - rows = cursor.fetchmany(2) + rows = _unpack_fixed_rows( + cursor.fetchmany(2), + row_width=2, + error_message="hire idempotency row is invalid", + ) if not rows: return None - if len(rows) != 1 or len(rows[0]) != 2: + if len(rows) != 1: raise HireDecisionIntegrityError("hire idempotency row is invalid") created_record_id, stored_digest = rows[0] if not _is_operational_uuid(created_record_id) or type(stored_digest) is not str: @@ -282,8 +298,11 @@ class PostgresHireAcceptancePort: ``connection_factory`` must return a DB-API connection context manager whose successful exit commits and exceptional exit rolls back, as psycopg - connections do. Pooling, TLS, credentials, and database roles remain a - deployment concern outside this service package. + connections do. Fixed projections must cross this adapter boundary as exact + built-in list/tuple row collections containing exact built-in list/tuple + rows; custom row factories must normalize before durable evidence is read. + Pooling, TLS, credentials, and database roles remain a deployment concern + outside this service package. """ connection_factory: PostgresConnectionFactory @@ -328,14 +347,15 @@ def accept_hire( command.candidate_profile_id, ), ) - rows = cursor.fetchmany(2) + rows = _unpack_fixed_rows( + cursor.fetchmany(2), + row_width=7, + error_message="decision provenance row has an invalid shape", + ) if not rows: raise HireDecisionNotFound("confirmed hire decision with sealed evidence was not found") if len(rows) != 1: raise HireDecisionIntegrityError("multiple decision provenance rows matched the hire") - row = rows[0] - if len(row) != 7: - raise HireDecisionIntegrityError("decision provenance row has an invalid shape") ( decision_actor_reference, decision_purpose_code, @@ -344,7 +364,7 @@ def accept_hire( decided_at, evidence_set_id, transaction_recorded_at, - ) = row + ) = rows[0] if any( type(value) is not str From cf76786595f634d6d5ecb5f1e53fcf30859c76b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:04:29 +0900 Subject: [PATCH 031/269] test(people): reject executable generic mutation row containers --- ...stgres_mutation_row_container_integrity.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 services/people-api/tests/test_postgres_mutation_row_container_integrity.py diff --git a/services/people-api/tests/test_postgres_mutation_row_container_integrity.py b/services/people-api/tests/test_postgres_mutation_row_container_integrity.py new file mode 100644 index 000000000..fc7b77fe3 --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_row_container_integrity.py @@ -0,0 +1,104 @@ +"""Executable-container regressions for generic People PostgreSQL projections.""" + +from __future__ import annotations + +import pytest + +import orgmetra_people_api.postgres_mutations as postgres_mutations +from orgmetra_people_api.mutations import PeopleMutationIntegrityError + + +class _ExecutableRows(list[object]): + """Tripwire outer row collection that must be rejected before container hooks.""" + + calls = 0 + + def __bool__(self) -> bool: + """Fail if durable validation asks this untrusted collection for truthiness.""" + type(self).calls += 1 + raise AssertionError("outer durable row collection executed __bool__") + + def __len__(self) -> int: + """Fail if durable validation asks this untrusted collection for cardinality.""" + type(self).calls += 1 + raise AssertionError("outer durable row collection executed __len__") + + def __getitem__(self, index: object) -> object: + """Fail if durable validation indexes this untrusted collection.""" + type(self).calls += 1 + raise AssertionError("outer durable row collection executed __getitem__") + + def __iter__(self): + """Fail if durable validation iterates this untrusted collection.""" + type(self).calls += 1 + raise AssertionError("outer durable row collection executed __iter__") + + +class _ExecutableRow(tuple[object, ...]): + """Tripwire fixed row that must be rejected before row hooks.""" + + calls = 0 + + def __len__(self) -> int: + """Fail if durable validation asks this untrusted row for width.""" + type(self).calls += 1 + raise AssertionError("durable row executed __len__") + + def __iter__(self): + """Fail if durable validation iterates this untrusted row.""" + type(self).calls += 1 + raise AssertionError("durable row executed __iter__") + + +@pytest.fixture(autouse=True) +def _reset_tripwires() -> None: + """Reset shared counters so each rejection proves zero callback execution.""" + _ExecutableRows.calls = 0 + _ExecutableRow.calls = 0 + + +def _unpack(value: object, *, row_width: int) -> tuple[tuple[object, ...], ...]: + """Resolve the production boundary explicitly so predecessor absence is RED.""" + unpack = getattr(postgres_mutations, "_unpack_fixed_rows", None) + assert unpack is not None, "generic People PostgreSQL adapter lacks a fixed-row trust boundary" + return unpack(value, row_width=row_width, error_message="durable projection is invalid") + + +def test_fixed_rows_reject_executable_outer_collection_before_hooks() -> None: + """Reject a list subtype before truthiness, length, indexing, or iteration executes.""" + rows = _ExecutableRows([(UUID_SENTINEL, "digest")]) + + with pytest.raises(PeopleMutationIntegrityError, match="durable projection is invalid"): + _unpack(rows, row_width=2) + + assert _ExecutableRows.calls == 0 + + +def test_fixed_rows_reject_executable_row_before_hooks() -> None: + """Reject a tuple subtype before width or iteration executes.""" + row = _ExecutableRow((UUID_SENTINEL, "digest")) + + with pytest.raises(PeopleMutationIntegrityError, match="durable projection is invalid"): + _unpack([row], row_width=2) + + assert _ExecutableRow.calls == 0 + + +def test_fixed_rows_reject_wrong_width_exact_row() -> None: + """Reject an inert built-in row whose SQL projection width is impossible.""" + with pytest.raises(PeopleMutationIntegrityError, match="durable projection is invalid"): + _unpack([(1, 2, 3)], row_width=2) + + +def test_fixed_rows_detach_exact_builtin_batches_and_rows() -> None: + """Accept exact built-in containers and return one inert tuple-of-tuples copy.""" + source = [[1, "a"], (2, "b")] + + detached = _unpack(source, row_width=2) + + assert detached == ((1, "a"), (2, "b")) + assert type(detached) is tuple + assert all(type(row) is tuple for row in detached) + + +UUID_SENTINEL = object() From 34c6e559768d98afa5353b110537188e2d7b9bd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:19:56 +0900 Subject: [PATCH 032/269] fix(people): exact-gate generic mutation row containers --- .../orgmetra_people_api/postgres_mutations.py | 88 +++++++++++++++---- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 63e01d086..9aa41286e 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -261,6 +261,23 @@ def _is_aware_datetime(value: object) -> bool: return isinstance(value, datetime) and value.tzinfo is not None and value.utcoffset() is not None +def _unpack_fixed_rows( + value: object, + *, + row_width: int, + error_message: str, +) -> tuple[tuple[object, ...], ...]: + """Detach exact built-in DB row containers before projection values are inspected.""" + if type(value) not in (list, tuple): + raise PeopleMutationIntegrityError(error_message) + detached: list[tuple[object, ...]] = [] + for row in value: + if type(row) not in (list, tuple) or len(row) != row_width: + raise PeopleMutationIntegrityError(error_message) + detached.append(tuple(row)) + return tuple(detached) + + def _replayed_record_id( cursor: Any, *, @@ -273,10 +290,14 @@ def _replayed_record_id( key_parameters = (command.tenant_record_id, route, command.idempotency_key) cursor.execute(_LOOKUP_IDEMPOTENCY_SQL, key_parameters) cursor.execute(_READ_IDEMPOTENCY_SQL, key_parameters) - rows = cursor.fetchmany(2) + rows = _unpack_fixed_rows( + cursor.fetchmany(2), + row_width=2, + error_message="idempotency row is invalid", + ) if not rows: return None - if len(rows) != 1 or len(rows[0]) != 2: + if len(rows) != 1: raise PeopleMutationIntegrityError("idempotency row is invalid") created_record_id, stored_digest = rows[0] if not _is_operational_uuid(created_record_id) or not isinstance(stored_digest, str): @@ -485,15 +506,18 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass ) -def _require_one_conversion(rows: list[tuple[object, ...]]) -> tuple[UUID, datetime]: +def _require_one_conversion(rows: object) -> tuple[UUID, datetime]: """Require exactly one current conversion row and a usable transaction timestamp.""" - if not rows: + detached = _unpack_fixed_rows( + rows, + row_width=2, + error_message="conversion row has an invalid shape", + ) + if not detached: raise PeopleMutationIntegrityError("person has no governed candidate-worker conversion") - if len(rows) != 1: + if len(detached) != 1: raise PeopleMutationIntegrityError("multiple candidate-worker conversions matched the person") - if len(rows[0]) != 2: - raise PeopleMutationIntegrityError("conversion row has an invalid shape") - conversion_id, recorded_at = rows[0] + conversion_id, recorded_at = detached[0] if not _is_operational_uuid(conversion_id) or not _is_aware_datetime(recorded_at): raise PeopleMutationIntegrityError("conversion identity or transaction time is invalid") assert isinstance(conversion_id, UUID) @@ -504,8 +528,12 @@ def _require_one_conversion(rows: list[tuple[object, ...]]) -> tuple[UUID, datet def _post_lock_recorded_at(cursor: Any) -> datetime: """Read one database clock instant only after the relevant conflict lock is held.""" cursor.execute(_POST_LOCK_RECORDED_AT_SQL) - rows = cursor.fetchmany(2) - if len(rows) != 1 or len(rows[0]) != 1 or not _is_aware_datetime(rows[0][0]): + rows = _unpack_fixed_rows( + cursor.fetchmany(2), + row_width=1, + error_message="post-lock database clock row is invalid", + ) + if len(rows) != 1 or not _is_aware_datetime(rows[0][0]): raise PeopleMutationIntegrityError("post-lock database clock row is invalid") recorded_at = rows[0][0] assert isinstance(recorded_at, datetime) @@ -517,7 +545,9 @@ class PostgresPeopleMutationPort: """Persist People mutations and governance evidence in one DB transaction. ``connection_factory`` must return a DB-API connection context manager whose - successful exit commits and exceptional exit rolls back. + successful exit commits and exceptional exit rolls back. Fixed query + projections must arrive as exact built-in list/tuple batches and rows; + custom row factories must normalize before this trust boundary. """ connection_factory: PostgresConnectionFactory @@ -557,9 +587,14 @@ def create_employment( _EMPLOYMENT_VERSIONS_SQL, (command.tenant_record_id, command.person_record_id), ) + existing_rows = _unpack_fixed_rows( + cursor.fetchall(), + row_width=9, + error_message="employment version row has an invalid shape", + ) existing = [ _employment_version_from_row(command.tenant_record_id, row) - for row in cursor.fetchall() + for row in existing_rows ] proposed = EmploymentVersion( tenant_record_id=command.tenant_record_id, @@ -657,10 +692,14 @@ def create_position( _POSITION_PARENTS_SQL, (command.job_profile_id, command.tenant_record_id, command.organization_unit_id), ) - rows = cursor.fetchmany(2) + rows = _unpack_fixed_rows( + cursor.fetchmany(2), + row_width=3, + error_message="position parent row is invalid", + ) if not rows: raise PeopleMutationNotFound("organization unit or job profile was not found") - if len(rows) != 1 or len(rows[0]) != 3: + if len(rows) != 1: raise PeopleMutationIntegrityError("position parent row is invalid") organization_unit_id, job_profile_id, recorded_at = rows[0] if ( @@ -749,17 +788,27 @@ def create_assignment( _NAMED_EMPLOYMENT_VERSIONS_SQL, (command.tenant_record_id, command.employment_record_id), ) + employment_rows = _unpack_fixed_rows( + cursor.fetchall(), + row_width=9, + error_message="employment version row has an invalid shape", + ) employment_versions = [ _employment_version_from_row(command.tenant_record_id, row) - for row in cursor.fetchall() + for row in employment_rows ] cursor.execute( _NAMED_POSITION_VERSIONS_SQL, (command.tenant_record_id, command.position_record_id), ) + position_rows = _unpack_fixed_rows( + cursor.fetchall(), + row_width=7, + error_message="position version row has an invalid shape", + ) position_versions = [ _position_version_from_row(command.tenant_record_id, row) - for row in cursor.fetchall() + for row in position_rows ] recorded_at = _post_lock_recorded_at(cursor) cursor.execute( @@ -770,9 +819,14 @@ def create_assignment( command.position_record_id, ), ) + assignment_rows = _unpack_fixed_rows( + cursor.fetchall(), + row_width=9, + error_message="assignment row has an invalid shape", + ) existing_assignments = [ _assignment_from_row(command.tenant_record_id, row) - for row in cursor.fetchall() + for row in assignment_rows ] proposed = AssignmentFact( tenant_record_id=command.tenant_record_id, From e7f84b28bb4ff23f935b311bd0669292033e1f95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:25:01 +0900 Subject: [PATCH 033/269] test(people): reject executable generic mutation durable scalars --- ...tgres_mutation_scalar_runtime_integrity.py | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py new file mode 100644 index 000000000..f9f7218fa --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py @@ -0,0 +1,81 @@ +"""Runtime-integrity contracts for generic People durable scalar evidence.""" + +from datetime import datetime, timedelta, timezone, tzinfo +from uuid import UUID +from zoneinfo import ZoneInfo + +from orgmetra_people_api.postgres_mutations import _is_aware_datetime, _is_operational_uuid + + +_MAX_UUID_INT = (1 << 128) - 1 + + +class _ExecutableUUID(UUID): + """Expose UUID attribute inspection performed before an exact-type gate.""" + + def __getattribute__(self, name: str) -> object: + """Fail when untrusted UUID evidence is inspected as if it were inert.""" + if name == "int": + raise AssertionError("UUID subtype behavior executed before exact-type validation") + return super().__getattribute__(name) + + +class _ExecutableTimezone(tzinfo): + """Record forbidden offset resolution at the generic People durable boundary.""" + + def __init__(self) -> None: + """Initialize the callback counter without resolving an offset.""" + self.calls = 0 + + def utcoffset(self, dt: datetime | None) -> timedelta: + """Fail if validation executes caller-defined timezone behavior.""" + del dt + self.calls += 1 + raise AssertionError("caller-defined timezone callback executed") + + +class _ExecutableDatetime(datetime): + """Fail if validation executes behavior from a datetime subtype.""" + + def utcoffset(self) -> timedelta: + """Expose subtype execution if the exact-type gate is missing.""" + raise AssertionError("datetime subtype callback executed") + + +def test_generic_durable_uuid_rejects_subtype_before_identity_inspection() -> None: + """DB-returned UUID subtypes fail without executing subtype behavior.""" + value = _ExecutableUUID("0198a412-7100-7000-8000-000000000061") + + assert _is_operational_uuid(value) is False + + +def test_generic_durable_uuid_accepts_only_operational_exact_uuid_values() -> None: + """Exact Psycopg-compatible UUIDs remain valid except reserved sentinels.""" + assert _is_operational_uuid(UUID("0198a412-7100-7000-8000-000000000061")) is True + assert _is_operational_uuid(UUID(int=0)) is False + assert _is_operational_uuid(UUID(int=_MAX_UUID_INT)) is False + + +def test_generic_timestamp_rejects_custom_timezone_before_callback() -> None: + """Exact datetime values cannot delegate offset validation to caller code.""" + provider = _ExecutableTimezone() + value = datetime(2026, 9, 5, 0, 0, tzinfo=provider) + + assert _is_aware_datetime(value) is False + assert provider.calls == 0 + + +def test_generic_timestamp_rejects_datetime_subtype_before_callback() -> None: + """Executable datetime subtypes are not durable generic People evidence.""" + value = _ExecutableDatetime(2026, 9, 5, 0, 0, tzinfo=timezone.utc) + + assert _is_aware_datetime(value) is False + + +def test_generic_timestamp_accepts_exact_standard_library_timezones() -> None: + """Psycopg-compatible standard-library timezone materialization stays valid.""" + utc_value = datetime(2026, 9, 5, 0, 0, tzinfo=timezone.utc) + seoul_value = datetime(2026, 9, 5, 9, 0, tzinfo=ZoneInfo("Asia/Seoul")) + + assert _is_aware_datetime(utc_value) is True + assert _is_aware_datetime(seoul_value) is True From 36bdc32e52baf21cc8b3a716b1cef0af7b992ea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:26:19 +0900 Subject: [PATCH 034/269] fix(people): exact-gate generic mutation durable scalars --- .../src/orgmetra_people_api/postgres_mutations.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 9aa41286e..d49dab94a 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -10,10 +10,11 @@ from contextlib import AbstractContextManager from dataclasses import dataclass -from datetime import date, datetime +from datetime import date, datetime, timezone from decimal import Decimal from typing import Any, Callable from uuid import UUID +from zoneinfo import ZoneInfo from orgmetra_hris_kernel import ( AssignmentFact, @@ -252,13 +253,17 @@ def _is_operational_uuid(value: object) -> bool: - """Return whether a value is an Orgmetra operational UUID.""" - return isinstance(value, UUID) and value.int not in (0, _MAX_UUID_INT) + """Return whether a value is an exact operational UUID.""" + return type(value) is UUID and value.int not in (0, _MAX_UUID_INT) def _is_aware_datetime(value: object) -> bool: - """Return whether a value is a timezone-aware datetime with a real offset.""" - return isinstance(value, datetime) and value.tzinfo is not None and value.utcoffset() is not None + """Return whether durable time is exact and backed by an inert standard provider.""" + if type(value) is not datetime or value.tzinfo is None: + return False + if type(value.tzinfo) not in (timezone, ZoneInfo): + return False + return value.utcoffset() is not None def _unpack_fixed_rows( From 0498eaa17e4e3a74c0e50994076da93e61830eb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:32:04 +0900 Subject: [PATCH 035/269] test(people): reject executable generic replay digest --- ...tgres_mutation_scalar_runtime_integrity.py | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py index f9f7218fa..12f28ae63 100644 --- a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py @@ -1,10 +1,18 @@ """Runtime-integrity contracts for generic People durable scalar evidence.""" from datetime import datetime, timedelta, timezone, tzinfo +from typing import Any from uuid import UUID from zoneinfo import ZoneInfo -from orgmetra_people_api.postgres_mutations import _is_aware_datetime, _is_operational_uuid +from orgmetra_people_api.mutations import PeopleMutationIntegrityError +from orgmetra_people_api.postgres_mutations import ( + _is_aware_datetime, + _is_operational_uuid, + _replayed_record_id, +) +from test_people_mutations import employment_command +from test_postgres_people_mutations import employment_authorization _MAX_UUID_INT = (1 << 128) - 1 @@ -42,6 +50,44 @@ def utcoffset(self) -> timedelta: raise AssertionError("datetime subtype callback executed") +class _ExecutableDigest(str): + """Expose digest comparison performed before an exact-type gate.""" + + def __new__(cls, value: str) -> _ExecutableDigest: + """Create one tripwire text value without invoking comparison behavior.""" + instance = super().__new__(cls, value) + instance.calls = 0 + return instance + + def __eq__(self, other: object) -> bool: + """Fail if durable replay validation executes subtype equality.""" + del other + self.calls += 1 + raise AssertionError("digest subtype equality executed before exact-type validation") + + def __ne__(self, other: object) -> bool: + """Fail if durable replay validation executes subtype inequality.""" + del other + self.calls += 1 + raise AssertionError("digest subtype inequality executed before exact-type validation") + + +class _ReplayCursor: + """Return one scripted exact built-in idempotency row.""" + + def __init__(self, row: tuple[object, object]) -> None: + """Store the row without inspecting its scalar values.""" + self.row = row + + def execute(self, sql: str, parameters: tuple[object, ...] | None = None) -> None: + """Accept the two replay lookup statements without side effects.""" + del sql, parameters + + def fetchmany(self, size: int) -> list[tuple[object, object]]: + """Return the scripted exact row within the requested bound.""" + return [self.row][:size] + + def test_generic_durable_uuid_rejects_subtype_before_identity_inspection() -> None: """DB-returned UUID subtypes fail without executing subtype behavior.""" value = _ExecutableUUID("0198a412-7100-7000-8000-000000000061") @@ -79,3 +125,23 @@ def test_generic_timestamp_accepts_exact_standard_library_timezones() -> None: assert _is_aware_datetime(utc_value) is True assert _is_aware_datetime(seoul_value) is True + + +def test_generic_replay_digest_rejects_subtype_before_comparison() -> None: + """Persisted digest subtypes fail without executing equality behavior.""" + command = employment_command() + digest = _ExecutableDigest("persisted-untrusted-digest") + cursor: Any = _ReplayCursor((command.employment_record_id, digest)) + + try: + _replayed_record_id( + cursor, + command=command, + authorization=employment_authorization(), + ) + except PeopleMutationIntegrityError as error: + assert str(error) == "idempotency row is invalid" + else: + raise AssertionError("digest subtype was not rejected") + + assert digest.calls == 0 From 55033e7d00023e0757971c63c1d893904f70332d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:33:29 +0900 Subject: [PATCH 036/269] fix(people): exact-gate generic replay digest --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index d49dab94a..9eec078c5 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -305,7 +305,7 @@ def _replayed_record_id( if len(rows) != 1: raise PeopleMutationIntegrityError("idempotency row is invalid") created_record_id, stored_digest = rows[0] - if not _is_operational_uuid(created_record_id) or not isinstance(stored_digest, str): + if not _is_operational_uuid(created_record_id) or type(stored_digest) is not str: raise PeopleMutationIntegrityError("idempotency row is invalid") if stored_digest != digest: raise PeopleMutationIntegrityError("idempotency key is bound to a different command") From 19f21291fadd9f0ca58aa30e2e8c554299757d69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:04:28 +0900 Subject: [PATCH 037/269] test(people): reject executable persisted status text --- ...tgres_mutation_scalar_runtime_integrity.py | 104 +++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py index 12f28ae63..0f5e07dd3 100644 --- a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py @@ -1,14 +1,16 @@ """Runtime-integrity contracts for generic People durable scalar evidence.""" -from datetime import datetime, timedelta, timezone, tzinfo +from datetime import date, datetime, timedelta, timezone, tzinfo from typing import Any from uuid import UUID from zoneinfo import ZoneInfo from orgmetra_people_api.mutations import PeopleMutationIntegrityError from orgmetra_people_api.postgres_mutations import ( + _employment_version_from_row, _is_aware_datetime, _is_operational_uuid, + _position_version_from_row, _replayed_record_id, ) from test_people_mutations import employment_command @@ -72,6 +74,33 @@ def __ne__(self, other: object) -> bool: raise AssertionError("digest subtype inequality executed before exact-type validation") +class _ExecutableStatusText(str): + """Expose persisted status-code behavior before an exact durable type gate.""" + + def __new__(cls, value: str) -> _ExecutableStatusText: + """Create one status-code tripwire without invoking comparison behavior.""" + instance = super().__new__(cls, value) + instance.calls = 0 + return instance + + def __hash__(self) -> int: + """Fail if HRIS validation hashes persisted subtype text.""" + self.calls += 1 + raise AssertionError("status subtype hashing executed before exact-type validation") + + def __eq__(self, other: object) -> bool: + """Fail if HRIS validation compares persisted subtype text.""" + del other + self.calls += 1 + raise AssertionError("status subtype equality executed before exact-type validation") + + def __ne__(self, other: object) -> bool: + """Fail if HRIS validation compares persisted subtype text for inequality.""" + del other + self.calls += 1 + raise AssertionError("status subtype inequality executed before exact-type validation") + + class _ReplayCursor: """Return one scripted exact built-in idempotency row.""" @@ -145,3 +174,76 @@ def test_generic_replay_digest_rejects_subtype_before_comparison() -> None: raise AssertionError("digest subtype was not rejected") assert digest.calls == 0 + + +def test_employment_projection_rejects_status_subtype_before_kernel_behavior() -> None: + """Persisted Employment status text must be inert before HRIS fact construction.""" + status = _ExecutableStatusText("active") + row = ( + UUID("0198a412-7100-7000-8000-000000000071"), + UUID("0198a412-7100-7000-8000-000000000072"), + UUID("0198a412-7100-7000-8000-000000000073"), + status, + "exclusive", + date(2026, 9, 5), + None, + datetime(2026, 9, 5, 0, 0, tzinfo=timezone.utc), + None, + ) + + try: + _employment_version_from_row(UUID("0198a412-7100-7000-8000-000000000070"), row) + except PeopleMutationIntegrityError as error: + assert str(error) == "employment version row is invalid" + else: + raise AssertionError("employment status subtype was not rejected") + + assert status.calls == 0 + + +def test_employment_projection_rejects_concurrency_subtype_before_kernel_behavior() -> None: + """Persisted concurrency text must be inert before exclusivity validation can hash it.""" + concurrency = _ExecutableStatusText("exclusive") + row = ( + UUID("0198a412-7100-7000-8000-000000000081"), + UUID("0198a412-7100-7000-8000-000000000082"), + UUID("0198a412-7100-7000-8000-000000000083"), + "active", + concurrency, + date(2026, 9, 5), + None, + datetime(2026, 9, 5, 0, 0, tzinfo=timezone.utc), + None, + ) + + try: + _employment_version_from_row(UUID("0198a412-7100-7000-8000-000000000080"), row) + except PeopleMutationIntegrityError as error: + assert str(error) == "employment version row is invalid" + else: + raise AssertionError("employment concurrency subtype was not rejected") + + assert concurrency.calls == 0 + + +def test_position_projection_rejects_status_subtype_before_kernel_behavior() -> None: + """Persisted Position status text must be exact before assignment validation can compare it.""" + status = _ExecutableStatusText("active") + row = ( + UUID("0198a412-7100-7000-8000-000000000091"), + UUID("0198a412-7100-7000-8000-000000000092"), + status, + date(2026, 9, 5), + None, + datetime(2026, 9, 5, 0, 0, tzinfo=timezone.utc), + None, + ) + + try: + _position_version_from_row(UUID("0198a412-7100-7000-8000-000000000090"), row) + except PeopleMutationIntegrityError as error: + assert str(error) == "position version row is invalid" + else: + raise AssertionError("position status subtype was not rejected") + + assert status.calls == 0 From 2c0b1140a8a93af74560b4c98cc6d251c0bf347c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:08:15 +0900 Subject: [PATCH 038/269] fix(people): exact-gate persisted status codes --- .../src/orgmetra_people_api/postgres_mutations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 9eec078c5..22b5bf26c 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -404,8 +404,8 @@ def _employment_version_from_row(tenant_record_id: UUID, row: tuple[object, ...] not _is_operational_uuid(employment_record_id) or not _is_operational_uuid(employment_record_version_id) or not _is_operational_uuid(person_record_id) - or not isinstance(status_code, str) - or not isinstance(concurrency_code, str) + or type(status_code) is not str + or type(concurrency_code) is not str or type(effective_from) is not date or (effective_to is not None and type(effective_to) is not date) or not _is_aware_datetime(recorded_from) @@ -445,7 +445,7 @@ def _position_version_from_row(tenant_record_id: UUID, row: tuple[object, ...]) if ( not _is_operational_uuid(position_record_id) or not _is_operational_uuid(position_record_version_id) - or not isinstance(status_code, str) + or type(status_code) is not str or type(effective_from) is not date or (effective_to is not None and type(effective_to) is not date) or not _is_aware_datetime(recorded_from) From ad1c1dbd2dc1a185ba6a81178cb99b37e18b4ae8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:12:35 +0900 Subject: [PATCH 039/269] test(people): reject executable persisted allocation Decimal --- ...tgres_mutation_scalar_runtime_integrity.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py index 0f5e07dd3..035a467a1 100644 --- a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py @@ -1,12 +1,14 @@ """Runtime-integrity contracts for generic People durable scalar evidence.""" from datetime import date, datetime, timedelta, timezone, tzinfo +from decimal import Decimal from typing import Any from uuid import UUID from zoneinfo import ZoneInfo from orgmetra_people_api.mutations import PeopleMutationIntegrityError from orgmetra_people_api.postgres_mutations import ( + _assignment_from_row, _employment_version_from_row, _is_aware_datetime, _is_operational_uuid, @@ -101,6 +103,40 @@ def __ne__(self, other: object) -> bool: raise AssertionError("status subtype inequality executed before exact-type validation") +class _ExecutableDecimal(Decimal): + """Expose persisted allocation-ratio behavior before an exact durable type gate.""" + + def __new__(cls, value: str) -> _ExecutableDecimal: + """Create one Decimal tripwire without performing portfolio arithmetic.""" + instance = super().__new__(cls, value) + instance.calls = 0 + return instance + + def __gt__(self, other: object) -> bool: + """Fail if FTE validation compares persisted subtype allocation.""" + del other + self.calls += 1 + raise AssertionError("Decimal subtype comparison executed before exact-type validation") + + def __le__(self, other: object) -> bool: + """Fail if FTE validation compares persisted subtype allocation.""" + del other + self.calls += 1 + raise AssertionError("Decimal subtype comparison executed before exact-type validation") + + def __add__(self, other: object) -> Decimal: + """Fail if portfolio aggregation adds persisted subtype allocation.""" + del other + self.calls += 1 + raise AssertionError("Decimal subtype addition executed before exact-type validation") + + def __radd__(self, other: object) -> Decimal: + """Fail if portfolio aggregation reverse-adds persisted subtype allocation.""" + del other + self.calls += 1 + raise AssertionError("Decimal subtype reverse addition executed before exact-type validation") + + class _ReplayCursor: """Return one scripted exact built-in idempotency row.""" @@ -247,3 +283,28 @@ def test_position_projection_rejects_status_subtype_before_kernel_behavior() -> raise AssertionError("position status subtype was not rejected") assert status.calls == 0 + + +def test_assignment_projection_rejects_decimal_subtype_before_fte_math() -> None: + """Persisted allocation Decimal must be exact before portfolio comparison or summation.""" + allocation = _ExecutableDecimal("0.5000") + row = ( + UUID("0198a412-7100-7000-8000-0000000000a1"), + UUID("0198a412-7100-7000-8000-0000000000a2"), + UUID("0198a412-7100-7000-8000-0000000000a3"), + UUID("0198a412-7100-7000-8000-0000000000a4"), + allocation, + date(2026, 9, 5), + None, + datetime(2026, 9, 5, 0, 0, tzinfo=timezone.utc), + None, + ) + + try: + _assignment_from_row(UUID("0198a412-7100-7000-8000-0000000000a0"), row) + except PeopleMutationIntegrityError as error: + assert str(error) == "assignment row is invalid" + else: + raise AssertionError("assignment allocation Decimal subtype was not rejected") + + assert allocation.calls == 0 From 3a63e92c94113e1e75d6eef386c3ce9e86b165e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 07:13:45 +0900 Subject: [PATCH 040/269] fix(people): exact-gate persisted allocation Decimal --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 22b5bf26c..ce32beb9a 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -486,7 +486,7 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass or not _is_operational_uuid(employment_record_id) or not _is_operational_uuid(person_record_id) or not _is_operational_uuid(position_record_id) - or not isinstance(allocation_ratio, Decimal) + or type(allocation_ratio) is not Decimal or type(effective_from) is not date or (effective_to is not None and type(effective_to) is not date) or not _is_aware_datetime(recorded_from) From 11c30cd5d0514caf4096578a8337042e6a583e6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:02:24 +0900 Subject: [PATCH 041/269] test(people): reject executable Position parent UUID evidence --- ..._position_parent_uuid_runtime_integrity.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 services/people-api/tests/test_postgres_position_parent_uuid_runtime_integrity.py diff --git a/services/people-api/tests/test_postgres_position_parent_uuid_runtime_integrity.py b/services/people-api/tests/test_postgres_position_parent_uuid_runtime_integrity.py new file mode 100644 index 000000000..96a9353c1 --- /dev/null +++ b/services/people-api/tests/test_postgres_position_parent_uuid_runtime_integrity.py @@ -0,0 +1,48 @@ +"""Runtime-integrity contract for durable Position parent identities.""" + +from uuid import UUID + +from orgmetra_people_api.mutations import PeopleMutationIntegrityError +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from test_people_mutations import JOB, ORGANIZATION, position_command +from test_postgres_people_mutations import ( + FakeConnection, + RECORDED_AT, + ScriptedCursor, + position_authorization, +) + + +class _ExecutableComparableUUID(UUID): + """Expose parent-identity comparison performed before an exact-type gate.""" + + def __eq__(self, other: object) -> bool: + """Fail if durable parent validation executes subtype equality.""" + del other + raise AssertionError("UUID subtype equality executed before exact-type validation") + + def __ne__(self, other: object) -> bool: + """Fail if durable parent validation executes subtype inequality.""" + del other + raise AssertionError("UUID subtype inequality executed before exact-type validation") + + +def test_position_parent_uuid_subtypes_reject_before_identity_comparison() -> None: + """Organization and Job UUID subtypes fail before their comparison hooks execute.""" + for parent_index, expected_parent in enumerate((ORGANIZATION, JOB)): + parent_uuid = _ExecutableComparableUUID(str(expected_parent)) + parent_row: list[object] = [ORGANIZATION, JOB, RECORDED_AT] + parent_row[parent_index] = parent_uuid + cursor = ScriptedCursor([[], [tuple(parent_row)]], []) + connection = FakeConnection(cursor) + port = PostgresPeopleMutationPort(lambda: connection) + + try: + port.create_position( + command=position_command(), + authorization=position_authorization(), + ) + except PeopleMutationIntegrityError as error: + assert str(error) == "position parent identity is invalid" + else: + raise AssertionError("position parent UUID subtype was not rejected") From 46884c24886654edb86a2233e690fd2cb4473c3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:04:06 +0900 Subject: [PATCH 042/269] fix(people): validate Position parent UUIDs before equality --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index ce32beb9a..d75e6a5c8 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -708,7 +708,9 @@ def create_position( raise PeopleMutationIntegrityError("position parent row is invalid") organization_unit_id, job_profile_id, recorded_at = rows[0] if ( - organization_unit_id != command.organization_unit_id + not _is_operational_uuid(organization_unit_id) + or not _is_operational_uuid(job_profile_id) + or organization_unit_id != command.organization_unit_id or job_profile_id != command.job_profile_id or not _is_aware_datetime(recorded_at) ): From 3e7eb2022a49a94f6ce383ef71df3bd18ffd0cb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:31:09 +0900 Subject: [PATCH 043/269] test(people): prove post-construction mutation escapes runtime integrity --- ...le_mutation_post_construction_integrity.py | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_post_construction_integrity.py diff --git a/services/people-api/tests/test_people_mutation_post_construction_integrity.py b/services/people-api/tests/test_people_mutation_post_construction_integrity.py new file mode 100644 index 000000000..0475d877a --- /dev/null +++ b/services/people-api/tests/test_people_mutation_post_construction_integrity.py @@ -0,0 +1,190 @@ +"""Post-construction runtime-integrity regressions for governed People mutations.""" + +from __future__ import annotations + +from datetime import date +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + AssignmentMutationResult, + EmploymentMutationCommand, + EmploymentMutationResult, + PositionMutationCommand, + PositionMutationResult, + create_employment_record, + mutation_command_digest, +) + +TENANT = UUID("0198a412-a600-7000-8000-000000000001") +PERSON = UUID("0198a412-a600-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-a600-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-a600-7000-8000-000000000031") +AUDIT_EVENT = UUID("0198a412-a600-7000-8000-000000000080") +OUTBOX = UUID("0198a412-a600-7000-8000-000000000081") + + +class _ExecutableUUID(UUID): + """Trip if a rewritten UUID is observed before exact runtime revalidation.""" + + @property + def hex(self) -> str: + """Fail if resource-reference rendering executes before validation.""" + raise AssertionError("rewritten UUID hex behavior must not execute") + + def __str__(self) -> str: + """Fail if canonical rendering executes before validation.""" + raise AssertionError("rewritten UUID string behavior must not execute") + + +def _command() -> EmploymentMutationCommand: + """Build one initially valid exact employment mutation command.""" + return EmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=date(2026, 9, 5), + confirmation_reference="human_confirmation:post-construction-1", + evidence_version_code="employment-evidence-v1", + idempotency_key="post-construction-runtime-1", + ) + + +def _decision() -> AuthorizationDecision: + """Build exact authorization evidence for the employment command.""" + fields = frozenset({"employment_record"}) + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-226", + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + policy_version_code="people-mutation-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind="employment_record", + requested_fields=fields, + authorized_fields=fields, + reason_code="access_permitted", + next_action="continue", + ) + + +def _principal() -> AuthenticatedPrincipal: + """Build one exact authenticated principal for service-boundary testing.""" + return AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-226", + granted_scope_codes=frozenset({"orgmetra.people.write"}), + ) + + +def _policy() -> PurposeBoundAccessPolicy: + """Build one exact purpose-bound policy for employment creation.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people-mutation-v1", + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="create_record", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({"employment_record"}), + ) + + +class _ResultPort: + """Return one supplied employment result while satisfying the mutation protocol.""" + + def __init__(self, result: EmploymentMutationResult) -> None: + """Retain the exact result supplied by the regression.""" + self.result = result + + def create_employment( + self, + *, + command: EmploymentMutationCommand, + authorization: AuthorizationDecision, + ) -> EmploymentMutationResult: + """Return the supplied employment result without changing it.""" + del command, authorization + return self.result + + def create_position( + self, + *, + command: PositionMutationCommand, + authorization: AuthorizationDecision, + ) -> PositionMutationResult: + """Reject unrelated position work in this focused regression port.""" + del command, authorization + raise AssertionError("position mutation is outside this regression") + + def create_assignment( + self, + *, + command: AssignmentMutationCommand, + authorization: AuthorizationDecision, + ) -> AssignmentMutationResult: + """Reject unrelated assignment work in this focused regression port.""" + del command, authorization + raise AssertionError("assignment mutation is outside this regression") + + +def test_digest_revalidates_exact_command_after_object_setattr_rewrite() -> None: + """Canonical digesting must reject rewritten command evidence before callbacks.""" + command = _command() + object.__setattr__( + command, + "person_record_id", + _ExecutableUUID("0198a412-a600-7000-8000-000000000099"), + ) + + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + mutation_command_digest(command=command, authorization=_decision()) + + +def test_service_revalidates_exact_command_before_authorization_or_port_work() -> None: + """An exact command rewritten after construction must fail before field rendering.""" + command = _command() + object.__setattr__( + command, + "employment_record_id", + _ExecutableUUID("0198a412-a600-7000-8000-000000000098"), + ) + result = EmploymentMutationResult(employment_record_id=EMPLOYMENT) + + with pytest.raises(ValueError, match="employment_record_id must be an operational UUID"): + create_employment_record( + principal=_principal(), + command=command, + purpose_code="workforce_admin", + policy=_policy(), + mutation_port=_ResultPort(result), + ) + + +def test_service_revalidates_exact_result_after_port_rewrite() -> None: + """An exact result rewritten by a port must not cross the People service boundary.""" + result = EmploymentMutationResult(employment_record_id=EMPLOYMENT) + object.__setattr__( + result, + "employment_record_id", + _ExecutableUUID("0198a412-a600-7000-8000-000000000097"), + ) + + with pytest.raises(ValueError, match="employment_record_id must be an operational UUID"): + create_employment_record( + principal=_principal(), + command=_command(), + purpose_code="workforce_admin", + policy=_policy(), + mutation_port=_ResultPort(result), + ) From e278023faba22ef01ed96b9997b85fb88511fba5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:31:49 +0900 Subject: [PATCH 044/269] fix(people): revalidate mutation evidence at consumption boundaries --- services/people-api/src/orgmetra_people_api/mutations.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index ada1c59aa..544be6b5f 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -119,6 +119,7 @@ def mutation_command_digest( if type(authorization) is not AuthorizationDecision: raise TypeError("authorization must be an AuthorizationDecision") if type(command) is EmploymentMutationCommand: + EmploymentMutationCommand.__post_init__(command) route = "employment-records" semantic_command: dict[str, object] = { "confirmation_reference": command.confirmation_reference, @@ -129,6 +130,7 @@ def mutation_command_digest( "person_record_id": str(command.person_record_id), } elif type(command) is PositionMutationCommand: + PositionMutationCommand.__post_init__(command) route = "position-records" semantic_command = { "confirmation_reference": command.confirmation_reference, @@ -139,6 +141,7 @@ def mutation_command_digest( "position_status_code": command.position_status_code, } elif type(command) is AssignmentMutationCommand: + AssignmentMutationCommand.__post_init__(command) route = "assignment-records" semantic_command = { "allocation_ratio": _canonical_allocation_ratio(command.allocation_ratio), @@ -366,6 +369,7 @@ def create_employment_record( """Authorize the exact employment target before persisting worker employment truth.""" if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") + EmploymentMutationCommand.__post_init__(command) port = _require_port(mutation_port) authorization = authorize_resource_fields( principal=principal, @@ -381,6 +385,7 @@ def create_employment_record( result = port.create_employment(command=command, authorization=authorization) if type(result) is not EmploymentMutationResult: raise TypeError("mutation_port must return EmploymentMutationResult") + EmploymentMutationResult.__post_init__(result) return result @@ -395,6 +400,7 @@ def create_position_record( """Authorize the exact position target before persisting a staffable seat.""" if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") + PositionMutationCommand.__post_init__(command) port = _require_port(mutation_port) authorization = authorize_resource_fields( principal=principal, @@ -410,6 +416,7 @@ def create_position_record( result = port.create_position(command=command, authorization=authorization) if type(result) is not PositionMutationResult: raise TypeError("mutation_port must return PositionMutationResult") + PositionMutationResult.__post_init__(result) return result @@ -424,6 +431,7 @@ def create_assignment_record( """Authorize the exact assignment target before persisting seat allocation.""" if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") + AssignmentMutationCommand.__post_init__(command) port = _require_port(mutation_port) authorization = authorize_resource_fields( principal=principal, @@ -439,6 +447,7 @@ def create_assignment_record( result = port.create_assignment(command=command, authorization=authorization) if type(result) is not AssignmentMutationResult: raise TypeError("mutation_port must return AssignmentMutationResult") + AssignmentMutationResult.__post_init__(result) return result From c7e0319df8b8603e862c61dfe0f402d7f58fc243 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:34:54 +0900 Subject: [PATCH 045/269] test(people): prove rewritten command reaches PostgreSQL authority --- ...es_mutation_post_construction_integrity.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 services/people-api/tests/test_postgres_mutation_post_construction_integrity.py diff --git a/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py new file mode 100644 index 000000000..6d02c2923 --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py @@ -0,0 +1,79 @@ +"""Post-construction command-integrity regression for the PostgreSQL People port.""" + +from __future__ import annotations + +from datetime import date +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.mutations import EmploymentMutationCommand +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort + +TENANT = UUID("0198a412-a700-7000-8000-000000000001") +PERSON = UUID("0198a412-a700-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-a700-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-a700-7000-8000-000000000031") +AUDIT_EVENT = UUID("0198a412-a700-7000-8000-000000000080") +OUTBOX = UUID("0198a412-a700-7000-8000-000000000081") + + +class _ExecutableUUID(UUID): + """Trip if the PostgreSQL authority renders rewritten identity before validation.""" + + @property + def hex(self) -> str: + """Fail if authorization-reference rendering runs before command validation.""" + raise AssertionError("rewritten UUID hex behavior must not execute") + + +def _authorization() -> AuthorizationDecision: + """Build one exact authorization decision for the original employment identity.""" + fields = frozenset({"employment_record"}) + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-227", + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + policy_version_code="people-mutation-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind="employment_record", + requested_fields=fields, + authorized_fields=fields, + reason_code="access_permitted", + next_action="continue", + ) + + +def _forbidden_connection_factory() -> object: + """Fail if rewritten command evidence reaches PostgreSQL transaction work.""" + raise AssertionError("database work must not begin for a rewritten mutation command") + + +def test_postgres_port_revalidates_exact_command_after_object_setattr_rewrite() -> None: + """Reject rewritten command identity before callback or database work.""" + command = EmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=date(2026, 9, 5), + confirmation_reference="human_confirmation:post-construction-227", + evidence_version_code="employment-evidence-v1", + idempotency_key="post-construction-runtime-227", + ) + object.__setattr__( + command, + "employment_record_id", + _ExecutableUUID("0198a412-a700-7000-8000-000000000099"), + ) + port = PostgresPeopleMutationPort(_forbidden_connection_factory) + + with pytest.raises(ValueError, match="employment_record_id must be an operational UUID"): + port.create_employment(command=command, authorization=_authorization()) From 6826999598163aac10596c8eb1f71116a68ca952 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:36:24 +0900 Subject: [PATCH 046/269] fix(people): revalidate commands at PostgreSQL mutation entry --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index d75e6a5c8..0ceec0130 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -571,6 +571,7 @@ def create_employment( """Persist one employment after conversion and exclusivity checks.""" if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") + EmploymentMutationCommand.__post_init__(command) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, @@ -679,6 +680,7 @@ def create_position( """Persist one position after organization and job parent checks.""" if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") + PositionMutationCommand.__post_init__(command) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, @@ -775,6 +777,7 @@ def create_assignment( """Persist one assignment after conversion and kernel coverage checks.""" if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") + AssignmentMutationCommand.__post_init__(command) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, From 8c40a184acab06732ba88234910876247f215ceb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:43:43 +0900 Subject: [PATCH 047/269] test(people): cover all PostgreSQL rewritten command entries --- ...es_mutation_post_construction_integrity.py | 97 ++++++++++++++++--- 1 file changed, 85 insertions(+), 12 deletions(-) diff --git a/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py index 6d02c2923..251b1f146 100644 --- a/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py @@ -1,20 +1,30 @@ -"""Post-construction command-integrity regression for the PostgreSQL People port.""" +"""Post-construction command-integrity regressions for the PostgreSQL People port.""" from __future__ import annotations from datetime import date +from decimal import Decimal from uuid import UUID import pytest from orgmetra_keyverse_adapter import AuthorizationDecision -from orgmetra_people_api.mutations import EmploymentMutationCommand +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + EmploymentMutationCommand, + PositionMutationCommand, +) from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort TENANT = UUID("0198a412-a700-7000-8000-000000000001") PERSON = UUID("0198a412-a700-7000-8000-000000000020") EMPLOYMENT = UUID("0198a412-a700-7000-8000-000000000030") EMPLOYMENT_VERSION = UUID("0198a412-a700-7000-8000-000000000031") +ORGANIZATION = UUID("0198a412-a700-7000-8000-000000000040") +JOB_PROFILE = UUID("0198a412-a700-7000-8000-000000000050") +POSITION = UUID("0198a412-a700-7000-8000-000000000060") +POSITION_VERSION = UUID("0198a412-a700-7000-8000-000000000061") +ASSIGNMENT = UUID("0198a412-a700-7000-8000-000000000070") AUDIT_EVENT = UUID("0198a412-a700-7000-8000-000000000080") OUTBOX = UUID("0198a412-a700-7000-8000-000000000081") @@ -28,18 +38,18 @@ def hex(self) -> str: raise AssertionError("rewritten UUID hex behavior must not execute") -def _authorization() -> AuthorizationDecision: - """Build one exact authorization decision for the original employment identity.""" - fields = frozenset({"employment_record"}) +def _authorization(*, resource_kind: str, record_id: UUID) -> AuthorizationDecision: + """Build one exact authorization decision for an original mutation identity.""" + fields = frozenset({resource_kind}) return AuthorizationDecision( allowed=True, tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-227", - resource_reference=f"employment_record:{EMPLOYMENT.hex}", + resource_reference=f"{resource_kind}:{record_id.hex}", policy_version_code="people-mutation-v1", purpose_code="workforce_admin", operation_code="create_record", - resource_kind="employment_record", + resource_kind=resource_kind, requested_fields=fields, authorized_fields=fields, reason_code="access_permitted", @@ -52,8 +62,8 @@ def _forbidden_connection_factory() -> object: raise AssertionError("database work must not begin for a rewritten mutation command") -def test_postgres_port_revalidates_exact_command_after_object_setattr_rewrite() -> None: - """Reject rewritten command identity before callback or database work.""" +def test_postgres_employment_revalidates_exact_command_after_object_setattr_rewrite() -> None: + """Reject rewritten Employment identity before callback or database work.""" command = EmploymentMutationCommand( tenant_record_id=TENANT, person_record_id=PERSON, @@ -64,9 +74,9 @@ def test_postgres_port_revalidates_exact_command_after_object_setattr_rewrite() employment_status_code="active", employment_concurrency_code="exclusive", effective_from=date(2026, 9, 5), - confirmation_reference="human_confirmation:post-construction-227", + confirmation_reference="human_confirmation:post-construction-227-employment", evidence_version_code="employment-evidence-v1", - idempotency_key="post-construction-runtime-227", + idempotency_key="post-construction-runtime-227-employment", ) object.__setattr__( command, @@ -76,4 +86,67 @@ def test_postgres_port_revalidates_exact_command_after_object_setattr_rewrite() port = PostgresPeopleMutationPort(_forbidden_connection_factory) with pytest.raises(ValueError, match="employment_record_id must be an operational UUID"): - port.create_employment(command=command, authorization=_authorization()) + port.create_employment( + command=command, + authorization=_authorization(resource_kind="employment_record", record_id=EMPLOYMENT), + ) + + +def test_postgres_position_revalidates_exact_command_after_object_setattr_rewrite() -> None: + """Reject rewritten Position identity before callback or database work.""" + command = PositionMutationCommand( + tenant_record_id=TENANT, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB_PROFILE, + position_record_id=POSITION, + position_record_version_id=POSITION_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + position_status_code="open", + effective_from=date(2026, 9, 5), + confirmation_reference="human_confirmation:post-construction-227-position", + evidence_version_code="position-evidence-v1", + idempotency_key="post-construction-runtime-227-position", + ) + object.__setattr__( + command, + "position_record_id", + _ExecutableUUID("0198a412-a700-7000-8000-000000000098"), + ) + port = PostgresPeopleMutationPort(_forbidden_connection_factory) + + with pytest.raises(ValueError, match="position_record_id must be an operational UUID"): + port.create_position( + command=command, + authorization=_authorization(resource_kind="position_record", record_id=POSITION), + ) + + +def test_postgres_assignment_revalidates_exact_command_after_object_setattr_rewrite() -> None: + """Reject rewritten Assignment identity before callback or database work.""" + command = AssignmentMutationCommand( + tenant_record_id=TENANT, + employment_record_id=EMPLOYMENT, + person_record_id=PERSON, + position_record_id=POSITION, + assignment_record_id=ASSIGNMENT, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + allocation_ratio=Decimal("1.0000"), + effective_from=date(2026, 9, 5), + confirmation_reference="human_confirmation:post-construction-227-assignment", + evidence_version_code="assignment-evidence-v1", + idempotency_key="post-construction-runtime-227-assignment", + ) + object.__setattr__( + command, + "assignment_record_id", + _ExecutableUUID("0198a412-a700-7000-8000-000000000097"), + ) + port = PostgresPeopleMutationPort(_forbidden_connection_factory) + + with pytest.raises(ValueError, match="assignment_record_id must be an operational UUID"): + port.create_assignment( + command=command, + authorization=_authorization(resource_kind="assignment_record", record_id=ASSIGNMENT), + ) From 1ab0bd0dc22d73293d1472946b00fe8f98d08d25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:00:59 +0900 Subject: [PATCH 048/269] test(people): prove confirmed-hire post-construction integrity gap --- .../test_hire_post_construction_integrity.py | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 services/people-api/tests/test_hire_post_construction_integrity.py diff --git a/services/people-api/tests/test_hire_post_construction_integrity.py b/services/people-api/tests/test_hire_post_construction_integrity.py new file mode 100644 index 000000000..d22cdac8a --- /dev/null +++ b/services/people-api/tests/test_hire_post_construction_integrity.py @@ -0,0 +1,162 @@ +"""Reject post-construction rewrites at confirmed-hire consumer boundaries.""" + +from __future__ import annotations + +from datetime import date +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import ( + HireAcceptanceCommand, + HireAcceptanceResult, + accept_confirmed_hire, +) +from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort + +TENANT = UUID("0198a412-7800-7000-8000-000000000001") +SELECTION_DECISION = UUID("0198a412-7800-7000-8000-000000000002") +PERSON = UUID("0198a412-7800-7000-8000-000000000003") +EMPLOYMENT = UUID("0198a412-7800-7000-8000-000000000004") +CONVERSION = UUID("0198a412-7800-7000-8000-000000000005") + + +class _ExecutableUUID(UUID): + """Expose UUID rendering attempted before an exact runtime-type gate.""" + + def __getattribute__(self, name: str) -> object: + """Fail if a rewritten UUID is rendered before command revalidation.""" + if name == "hex": + raise AssertionError("UUID subtype behavior executed before command revalidation") + return super().__getattribute__(name) + + +class _RecordingPort: + """Record whether a rewritten command crosses the application boundary.""" + + def __init__(self) -> None: + """Start with no durable-port invocation.""" + self.called = False + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Return one valid result if the application incorrectly calls the port.""" + del authorization + self.called = True + return HireAcceptanceResult( + person_record_id=command.person_record_id, + employment_record_id=command.employment_record_id, + candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, + ) + + +class _RewrittenResultPort: + """Return an exact result whose identity was rewritten after construction.""" + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Rewrite one exact result after its constructor invariant has already run.""" + del command, authorization + result = HireAcceptanceResult( + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + candidate_worker_conversion_record_id=CONVERSION, + ) + object.__setattr__(result, "person_record_id", "not-a-uuid") + return result + + +def _command() -> HireAcceptanceCommand: + """Build one valid confirmed-hire command before deliberate low-level rewrite.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=UUID("0198a412-7800-7000-8000-000000000010"), + selection_decision_id=SELECTION_DECISION, + person_record_id=PERSON, + person_name_record_id=UUID("0198a412-7800-7000-8000-000000000011"), + employment_record_id=EMPLOYMENT, + employment_record_version_id=UUID("0198a412-7800-7000-8000-000000000012"), + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=UUID("0198a412-7800-7000-8000-000000000013"), + outbox_delivery_record_id=UUID("0198a412-7800-7000-8000-000000000014"), + effective_from=date(2026, 9, 5), + display_name="Ada Lovelace", + idempotency_key="hire-post-construction-228", + employment_status_code="active", + ) + + +def _principal() -> AuthenticatedPrincipal: + """Return the authenticated principal for the application-boundary regression.""" + return AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-228", + granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), + ) + + +def _policy() -> PurposeBoundAccessPolicy: + """Return the purpose-bound policy for confirmed-hire materialization.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="people-hire-v1", + resource_kind="selection_decision", + purpose_code="candidate_hire", + operation_code="materialize_worker", + required_scope_code="orgmetra.people.materialize_worker", + permitted_fields=frozenset({"candidate_worker_conversion"}), + ) + + +def _rewrite_selection_decision(command: HireAcceptanceCommand) -> None: + """Replace one validated UUID with executable subtype evidence after construction.""" + object.__setattr__( + command, + "selection_decision_id", + _ExecutableUUID("0198a412-7800-7000-8000-0000000000ff"), + ) + + +def _forbidden_connection_factory() -> object: + """Fail if a rewritten command reaches database acquisition.""" + raise AssertionError("database acquisition occurred before command revalidation") + + +def test_application_revalidates_rewritten_hire_command_before_authorization_rendering() -> None: + """A rewritten command must fail before UUID rendering or the mutation port.""" + command = _command() + _rewrite_selection_decision(command) + port = _RecordingPort() + + with pytest.raises(ValueError, match="selection_decision_id must be an operational UUID"): + accept_confirmed_hire( + principal=_principal(), + command=command, + purpose_code="candidate_hire", + policy=_policy(), + mutation_port=port, + ) + + assert port.called is False + + +def test_application_revalidates_rewritten_exact_hire_result_before_return() -> None: + """An exact result rewritten after construction must not leave the service boundary.""" + with pytest.raises(ValueError, match="person_record_id must be an operational UUID"): + accept_confirmed_hire( + principal=_principal(), + command=_command(), + purpose_code="candidate_hire", + policy=_policy(), + mutation_port=_RewrittenResultPort(), + ) + + +def test_postgres_port_revalidates_rewritten_hire_command_before_authorization_or_db() -> None: + """Direct durable-port entry must reject rewritten evidence before callbacks or DB work.""" + command = _command() + _rewrite_selection_decision(command) + port = PostgresHireAcceptancePort(connection_factory=_forbidden_connection_factory) + + with pytest.raises(ValueError, match="selection_decision_id must be an operational UUID"): + port.accept_hire(command=command, authorization=object()) # type: ignore[arg-type] From 0a394553c21df407ae4bafe8fee568c70d5e8e62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:02:04 +0900 Subject: [PATCH 049/269] fix(people): revalidate confirmed-hire application evidence --- services/people-api/src/orgmetra_people_api/hire.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 0d8bbbd7e..135cfd8b1 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -149,6 +149,7 @@ def accept_confirmed_hire( """ if type(command) is not HireAcceptanceCommand: raise TypeError("command must be a HireAcceptanceCommand") + HireAcceptanceCommand.__post_init__(command) if not isinstance(mutation_port, HireAcceptancePort): raise TypeError("mutation_port must implement HireAcceptancePort") @@ -166,4 +167,5 @@ def accept_confirmed_hire( result = mutation_port.accept_hire(command=command, authorization=authorization) if type(result) is not HireAcceptanceResult: raise TypeError("mutation_port must return HireAcceptanceResult") + HireAcceptanceResult.__post_init__(result) return result From bf24c2e43f75e6e9438b228abb959157b4f7a589 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 10:02:53 +0900 Subject: [PATCH 050/269] fix(people): revalidate confirmed-hire durable entry command --- services/people-api/src/orgmetra_people_api/postgres_hire.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 4bffbdb2d..9ec7ff291 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -330,6 +330,7 @@ def accept_hire( """ if type(command) is not HireAcceptanceCommand: raise TypeError("command must be a HireAcceptanceCommand") + HireAcceptanceCommand.__post_init__(command) decision = _validate_authorization(command, authorization) with self.connection_factory() as connection: From 2064a52a3c845f8b06d14296b8d5535e4c07a3c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:04:24 +0900 Subject: [PATCH 051/269] test(people): expose post-validation command rewrite --- ...es_mutation_post_construction_integrity.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py index 251b1f146..4e6858494 100644 --- a/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py @@ -15,12 +15,14 @@ PositionMutationCommand, ) from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from test_postgres_people_mutations import FakeConnection, RECORDED_AT, ScriptedCursor TENANT = UUID("0198a412-a700-7000-8000-000000000001") PERSON = UUID("0198a412-a700-7000-8000-000000000020") EMPLOYMENT = UUID("0198a412-a700-7000-8000-000000000030") EMPLOYMENT_VERSION = UUID("0198a412-a700-7000-8000-000000000031") ORGANIZATION = UUID("0198a412-a700-7000-8000-000000000040") +MUTATED_ORGANIZATION = UUID("0198a412-a700-7000-8000-000000000041") JOB_PROFILE = UUID("0198a412-a700-7000-8000-000000000050") POSITION = UUID("0198a412-a700-7000-8000-000000000060") POSITION_VERSION = UUID("0198a412-a700-7000-8000-000000000061") @@ -150,3 +152,48 @@ def test_postgres_assignment_revalidates_exact_command_after_object_setattr_rewr command=command, authorization=_authorization(resource_kind="assignment_record", record_id=ASSIGNMENT), ) + + +def test_postgres_position_detaches_validated_command_before_connection_factory_callback() -> None: + """Keep one validated Position snapshot across the caller-owned connection callback.""" + command = PositionMutationCommand( + tenant_record_id=TENANT, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB_PROFILE, + position_record_id=POSITION, + position_record_version_id=POSITION_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + position_status_code="open", + effective_from=date(2026, 9, 5), + confirmation_reference="human_confirmation:post-construction-229-position", + evidence_version_code="position-evidence-v1", + idempotency_key="post-construction-runtime-229-position", + ) + cursor = ScriptedCursor( + [[], [(ORGANIZATION, JOB_PROFILE, RECORDED_AT)]], + [], + ) + connection = FakeConnection(cursor) + + def mutating_connection_factory() -> FakeConnection: + """Rewrite the caller's still-valid command only after authorization has completed.""" + object.__setattr__(command, "organization_unit_id", MUTATED_ORGANIZATION) + return connection + + port = PostgresPeopleMutationPort(mutating_connection_factory) + result = port.create_position( + command=command, + authorization=_authorization(resource_kind="position_record", record_id=POSITION), + ) + + assert result.position_record_id == POSITION + parent_query = next( + execution for execution in cursor.executions if "FROM public.organization_unit AS organization" in execution[0] + ) + assert parent_query[1] == (JOB_PROFILE, TENANT, ORGANIZATION) + insert_position = next( + execution for execution in cursor.executions if execution[0].startswith("INSERT INTO public.position_record (") + ) + assert insert_position[1] is not None + assert insert_position[1][2] == ORGANIZATION From e4cab11d32f22dc40841acbc9cf86c082bb25f67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 11:05:58 +0900 Subject: [PATCH 052/269] fix(people): detach validated mutation commands before callbacks --- .../src/orgmetra_people_api/postgres_mutations.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 0ceec0130..ede1deefd 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -9,7 +9,7 @@ from __future__ import annotations from contextlib import AbstractContextManager -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date, datetime, timezone from decimal import Decimal from typing import Any, Callable @@ -571,7 +571,7 @@ def create_employment( """Persist one employment after conversion and exclusivity checks.""" if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") - EmploymentMutationCommand.__post_init__(command) + command = replace(command) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, @@ -680,7 +680,7 @@ def create_position( """Persist one position after organization and job parent checks.""" if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") - PositionMutationCommand.__post_init__(command) + command = replace(command) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, @@ -777,7 +777,7 @@ def create_assignment( """Persist one assignment after conversion and kernel coverage checks.""" if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") - AssignmentMutationCommand.__post_init__(command) + command = replace(command) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, From 0daa68a00ca3132069ea515672bcea09cb8a2345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:06:16 +0900 Subject: [PATCH 053/269] test(people): bind mutation results to commanded identities --- ...ople_mutation_result_identity_integrity.py | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_result_identity_integrity.py diff --git a/services/people-api/tests/test_people_mutation_result_identity_integrity.py b/services/people-api/tests/test_people_mutation_result_identity_integrity.py new file mode 100644 index 000000000..0871c8dc5 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_result_identity_integrity.py @@ -0,0 +1,301 @@ +"""Result-to-command identity regressions for governed People mutations.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import ( + HireAcceptanceCommand, + HireAcceptanceResult, + HireDecisionIntegrityError, + accept_confirmed_hire, +) +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + AssignmentMutationResult, + EmploymentMutationCommand, + EmploymentMutationResult, + PeopleMutationIntegrityError, + PositionMutationCommand, + PositionMutationResult, + create_assignment_record, + create_employment_record, + create_position_record, +) + +TENANT = UUID("0198a412-b100-7000-8000-000000000001") +PERSON = UUID("0198a412-b100-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-b100-7000-8000-000000000021") +CANDIDATE = UUID("0198a412-b100-7000-8000-000000000022") +SELECTION_DECISION = UUID("0198a412-b100-7000-8000-000000000023") +EMPLOYMENT = UUID("0198a412-b100-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-b100-7000-8000-000000000031") +POSITION = UUID("0198a412-b100-7000-8000-000000000040") +POSITION_VERSION = UUID("0198a412-b100-7000-8000-000000000041") +ORGANIZATION = UUID("0198a412-b100-7000-8000-000000000050") +JOB = UUID("0198a412-b100-7000-8000-000000000060") +ASSIGNMENT = UUID("0198a412-b100-7000-8000-000000000070") +CONVERSION = UUID("0198a412-b100-7000-8000-000000000071") +AUDIT_EVENT = UUID("0198a412-b100-7000-8000-000000000080") +OUTBOX = UUID("0198a412-b100-7000-8000-000000000081") +OTHER = UUID("0198a412-b100-7000-8000-000000000099") +EFFECTIVE_FROM = date(2026, 9, 5) + +PRINCIPAL = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:result-integrity-operator", + granted_scope_codes=frozenset( + { + "orgmetra.people.write", + "orgmetra.job_architecture.write", + "orgmetra.people.materialize_worker", + } + ), +) + + +def _employment_command() -> EmploymentMutationCommand: + """Build one governed Employment create command.""" + return EmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:result-integrity", + evidence_version_code="result-integrity-v1", + idempotency_key="result-integrity-employment-1", + ) + + +def _position_command() -> PositionMutationCommand: + """Build one governed Position create command.""" + return PositionMutationCommand( + tenant_record_id=TENANT, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB, + position_record_id=POSITION, + position_record_version_id=POSITION_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + position_status_code="open", + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:result-integrity", + evidence_version_code="result-integrity-v1", + idempotency_key="result-integrity-position-1", + ) + + +def _assignment_command() -> AssignmentMutationCommand: + """Build one governed Assignment create command.""" + return AssignmentMutationCommand( + tenant_record_id=TENANT, + employment_record_id=EMPLOYMENT, + person_record_id=PERSON, + position_record_id=POSITION, + assignment_record_id=ASSIGNMENT, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + allocation_ratio=Decimal("1.0000"), + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:result-integrity", + evidence_version_code="result-integrity-v1", + idempotency_key="result-integrity-assignment-1", + ) + + +def _hire_command() -> HireAcceptanceCommand: + """Build one governed confirmed-hire command.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=SELECTION_DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + effective_from=EFFECTIVE_FROM, + display_name="Result Integrity Worker", + idempotency_key="result-integrity-hire-1", + ) + + +def _policy( + *, + resource_kind: str, + purpose_code: str, + operation_code: str, + scope_code: str, + field_name: str, +) -> PurposeBoundAccessPolicy: + """Build one exact purpose-bound policy for a mutation target.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="result-integrity-v1", + resource_kind=resource_kind, + purpose_code=purpose_code, + operation_code=operation_code, + required_scope_code=scope_code, + permitted_fields=frozenset({field_name}), + ) + + +class _PeopleResultPort: + """Return supplied structurally valid results without honoring command identity.""" + + def __init__( + self, + *, + employment_result: EmploymentMutationResult | None = None, + position_result: PositionMutationResult | None = None, + assignment_result: AssignmentMutationResult | None = None, + ) -> None: + """Retain the result selected by each focused regression.""" + self.employment_result = employment_result + self.position_result = position_result + self.assignment_result = assignment_result + + def create_employment(self, *, command: EmploymentMutationCommand, authorization: object) -> EmploymentMutationResult: + """Return the configured Employment result.""" + del command, authorization + assert self.employment_result is not None + return self.employment_result + + def create_position(self, *, command: PositionMutationCommand, authorization: object) -> PositionMutationResult: + """Return the configured Position result.""" + del command, authorization + assert self.position_result is not None + return self.position_result + + def create_assignment(self, *, command: AssignmentMutationCommand, authorization: object) -> AssignmentMutationResult: + """Return the configured Assignment result.""" + del command, authorization + assert self.assignment_result is not None + return self.assignment_result + + +class _HireResultPort: + """Return one structurally valid confirmed-hire result supplied by the regression.""" + + def __init__(self, result: HireAcceptanceResult) -> None: + """Retain the result without deriving it from the command.""" + self.result = result + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Return the configured hire result.""" + del command, authorization + return self.result + + +class PeopleMutationResultIdentityTests(unittest.TestCase): + """Require generic People port results to name exactly the commanded records.""" + + def test_employment_result_must_match_command_identity(self) -> None: + """A valid but different Employment identity must fail closed.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "employment result identity"): + create_employment_record( + principal=PRINCIPAL, + command=_employment_command(), + purpose_code="workforce_admin", + policy=_policy( + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="create_record", + scope_code="orgmetra.people.write", + field_name="employment_record", + ), + mutation_port=_PeopleResultPort( + employment_result=EmploymentMutationResult(employment_record_id=OTHER) + ), + ) + + def test_position_result_must_match_command_identity(self) -> None: + """A valid but different Position identity must fail closed.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "position result identity"): + create_position_record( + principal=PRINCIPAL, + command=_position_command(), + purpose_code="job_architecture_admin", + policy=_policy( + resource_kind="position_record", + purpose_code="job_architecture_admin", + operation_code="create_record", + scope_code="orgmetra.job_architecture.write", + field_name="position_record", + ), + mutation_port=_PeopleResultPort(position_result=PositionMutationResult(position_record_id=OTHER)), + ) + + def test_assignment_result_must_match_command_identity(self) -> None: + """A valid but different Assignment identity must fail closed.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "assignment result identity"): + create_assignment_record( + principal=PRINCIPAL, + command=_assignment_command(), + purpose_code="workforce_admin", + policy=_policy( + resource_kind="assignment_record", + purpose_code="workforce_admin", + operation_code="create_record", + scope_code="orgmetra.people.write", + field_name="assignment_record", + ), + mutation_port=_PeopleResultPort( + assignment_result=AssignmentMutationResult(assignment_record_id=OTHER) + ), + ) + + +class HireResultIdentityTests(unittest.TestCase): + """Require confirmed-hire results to preserve every commanded authoritative identity.""" + + def test_hire_result_must_match_person_employment_and_conversion_identities(self) -> None: + """Any structurally valid but foreign hire identity must fail closed.""" + mismatched_results = ( + HireAcceptanceResult( + person_record_id=OTHER, + employment_record_id=EMPLOYMENT, + candidate_worker_conversion_record_id=CONVERSION, + ), + HireAcceptanceResult( + person_record_id=PERSON, + employment_record_id=OTHER, + candidate_worker_conversion_record_id=CONVERSION, + ), + HireAcceptanceResult( + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + candidate_worker_conversion_record_id=OTHER, + ), + ) + for result in mismatched_results: + with self.subTest(result=result), self.assertRaisesRegex(HireDecisionIntegrityError, "hire result identity"): + accept_confirmed_hire( + principal=PRINCIPAL, + command=_hire_command(), + purpose_code="candidate_hire", + policy=_policy( + resource_kind="selection_decision", + purpose_code="candidate_hire", + operation_code="materialize_worker", + scope_code="orgmetra.people.materialize_worker", + field_name="candidate_worker_conversion", + ), + mutation_port=_HireResultPort(result), + ) + + +if __name__ == "__main__": + unittest.main() From e09e557f956366b78b9e073d5f7cdb6d9e3e0790 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:07:27 +0900 Subject: [PATCH 054/269] fix(people): bind mutation results to command identities --- services/people-api/src/orgmetra_people_api/mutations.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 544be6b5f..a510ea579 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -386,6 +386,8 @@ def create_employment_record( if type(result) is not EmploymentMutationResult: raise TypeError("mutation_port must return EmploymentMutationResult") EmploymentMutationResult.__post_init__(result) + if result.employment_record_id != command.employment_record_id: + raise PeopleMutationIntegrityError("employment result identity does not match command") return result @@ -417,6 +419,8 @@ def create_position_record( if type(result) is not PositionMutationResult: raise TypeError("mutation_port must return PositionMutationResult") PositionMutationResult.__post_init__(result) + if result.position_record_id != command.position_record_id: + raise PeopleMutationIntegrityError("position result identity does not match command") return result @@ -448,6 +452,8 @@ def create_assignment_record( if type(result) is not AssignmentMutationResult: raise TypeError("mutation_port must return AssignmentMutationResult") AssignmentMutationResult.__post_init__(result) + if result.assignment_record_id != command.assignment_record_id: + raise PeopleMutationIntegrityError("assignment result identity does not match command") return result From 40ef9b2c0bf860d90fde071795f7a907e96651d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:07:43 +0900 Subject: [PATCH 055/269] fix(people): bind confirmed hire result identities --- services/people-api/src/orgmetra_people_api/hire.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 135cfd8b1..cb47905ba 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -168,4 +168,10 @@ def accept_confirmed_hire( if type(result) is not HireAcceptanceResult: raise TypeError("mutation_port must return HireAcceptanceResult") HireAcceptanceResult.__post_init__(result) + if ( + result.person_record_id != command.person_record_id + or result.employment_record_id != command.employment_record_id + or result.candidate_worker_conversion_record_id != command.candidate_worker_conversion_record_id + ): + raise HireDecisionIntegrityError("hire result identity does not match command") return result From 6142f7dd765727d80028ed147c31b6d9dea5fc63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:08:35 +0900 Subject: [PATCH 056/269] test(people): bind result checks to pre-port targets --- ..._people_mutation_result_target_snapshot.py | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_result_target_snapshot.py diff --git a/services/people-api/tests/test_people_mutation_result_target_snapshot.py b/services/people-api/tests/test_people_mutation_result_target_snapshot.py new file mode 100644 index 000000000..17d56c9c6 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_result_target_snapshot.py @@ -0,0 +1,202 @@ +"""Pre-port target binding regressions for People mutation results.""" + +from __future__ import annotations + +from datetime import date +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import ( + HireAcceptanceCommand, + HireAcceptanceResult, + HireDecisionIntegrityError, + accept_confirmed_hire, +) +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + AssignmentMutationResult, + EmploymentMutationCommand, + EmploymentMutationResult, + PeopleMutationIntegrityError, + PositionMutationCommand, + PositionMutationResult, + create_employment_record, +) + +TENANT = UUID("0198a412-b200-7000-8000-000000000001") +PERSON = UUID("0198a412-b200-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-b200-7000-8000-000000000021") +CANDIDATE = UUID("0198a412-b200-7000-8000-000000000022") +SELECTION_DECISION = UUID("0198a412-b200-7000-8000-000000000023") +EMPLOYMENT = UUID("0198a412-b200-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-b200-7000-8000-000000000031") +CONVERSION = UUID("0198a412-b200-7000-8000-000000000040") +AUDIT_EVENT = UUID("0198a412-b200-7000-8000-000000000050") +OUTBOX = UUID("0198a412-b200-7000-8000-000000000051") +OTHER = UUID("0198a412-b200-7000-8000-000000000099") +EFFECTIVE_FROM = date(2026, 9, 5) + +PRINCIPAL = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:target-snapshot-operator", + granted_scope_codes=frozenset( + {"orgmetra.people.write", "orgmetra.people.materialize_worker"} + ), +) + + +def _employment_command() -> EmploymentMutationCommand: + """Build one valid Employment command whose target can be rewritten by a port.""" + return EmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:target-snapshot", + evidence_version_code="target-snapshot-v1", + idempotency_key="target-snapshot-employment-1", + ) + + +def _hire_command() -> HireAcceptanceCommand: + """Build one valid hire command whose target can be rewritten by a port.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=SELECTION_DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + effective_from=EFFECTIVE_FROM, + display_name="Target Snapshot Worker", + idempotency_key="target-snapshot-hire-1", + ) + + +def _policy( + *, + resource_kind: str, + purpose_code: str, + operation_code: str, + scope_code: str, + field_name: str, +) -> PurposeBoundAccessPolicy: + """Build one exact policy for the focused mutation boundary.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="target-snapshot-v1", + resource_kind=resource_kind, + purpose_code=purpose_code, + operation_code=operation_code, + required_scope_code=scope_code, + permitted_fields=frozenset({field_name}), + ) + + +class _MutatingPeoplePort: + """Rewrite the caller command during the port call and report the rewritten identity.""" + + def create_employment( + self, + *, + command: EmploymentMutationCommand, + authorization: object, + ) -> EmploymentMutationResult: + """Replace the commanded Employment target before returning a valid result.""" + del authorization + object.__setattr__(command, "employment_record_id", OTHER) + return EmploymentMutationResult(employment_record_id=OTHER) + + def create_position( + self, + *, + command: PositionMutationCommand, + authorization: object, + ) -> PositionMutationResult: + """Reject unrelated Position work while satisfying the runtime protocol.""" + del command, authorization + raise AssertionError("position mutation is outside this regression") + + def create_assignment( + self, + *, + command: AssignmentMutationCommand, + authorization: object, + ) -> AssignmentMutationResult: + """Reject unrelated Assignment work while satisfying the runtime protocol.""" + del command, authorization + raise AssertionError("assignment mutation is outside this regression") + + +class _MutatingHirePort: + """Rewrite all hire targets during the port call and report those rewritten identities.""" + + def accept_hire( + self, + *, + command: HireAcceptanceCommand, + authorization: object, + ) -> HireAcceptanceResult: + """Replace authoritative targets after authorization but before service return.""" + del authorization + object.__setattr__(command, "person_record_id", OTHER) + object.__setattr__(command, "employment_record_id", OTHER) + object.__setattr__(command, "candidate_worker_conversion_record_id", OTHER) + return HireAcceptanceResult( + person_record_id=OTHER, + employment_record_id=OTHER, + candidate_worker_conversion_record_id=OTHER, + ) + + +class PeopleMutationResultTargetSnapshotTests(unittest.TestCase): + """Require result coherence against targets captured before executable port work.""" + + def test_employment_result_check_uses_pre_port_target(self) -> None: + """A port must not redefine the expected Employment identity by mutating the command.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "employment result identity"): + create_employment_record( + principal=PRINCIPAL, + command=_employment_command(), + purpose_code="workforce_admin", + policy=_policy( + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="create_record", + scope_code="orgmetra.people.write", + field_name="employment_record", + ), + mutation_port=_MutatingPeoplePort(), + ) + + def test_hire_result_check_uses_pre_port_targets(self) -> None: + """A port must not redefine Person/Employment/conversion result authority.""" + with self.assertRaisesRegex(HireDecisionIntegrityError, "hire result identity"): + accept_confirmed_hire( + principal=PRINCIPAL, + command=_hire_command(), + purpose_code="candidate_hire", + policy=_policy( + resource_kind="selection_decision", + purpose_code="candidate_hire", + operation_code="materialize_worker", + scope_code="orgmetra.people.materialize_worker", + field_name="candidate_worker_conversion", + ), + mutation_port=_MutatingHirePort(), + ) + + +if __name__ == "__main__": + unittest.main() From abd549202f5d8e7f88a212db318a3f19743e0e63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:09:29 +0900 Subject: [PATCH 057/269] fix(people): compare results to detached pre-port targets --- services/people-api/src/orgmetra_people_api/mutations.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index a510ea579..04448298d 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -370,6 +370,7 @@ def create_employment_record( if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") EmploymentMutationCommand.__post_init__(command) + expected_employment_record_id = UUID(int=command.employment_record_id.int) port = _require_port(mutation_port) authorization = authorize_resource_fields( principal=principal, @@ -386,7 +387,7 @@ def create_employment_record( if type(result) is not EmploymentMutationResult: raise TypeError("mutation_port must return EmploymentMutationResult") EmploymentMutationResult.__post_init__(result) - if result.employment_record_id != command.employment_record_id: + if result.employment_record_id != expected_employment_record_id: raise PeopleMutationIntegrityError("employment result identity does not match command") return result @@ -403,6 +404,7 @@ def create_position_record( if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") PositionMutationCommand.__post_init__(command) + expected_position_record_id = UUID(int=command.position_record_id.int) port = _require_port(mutation_port) authorization = authorize_resource_fields( principal=principal, @@ -419,7 +421,7 @@ def create_position_record( if type(result) is not PositionMutationResult: raise TypeError("mutation_port must return PositionMutationResult") PositionMutationResult.__post_init__(result) - if result.position_record_id != command.position_record_id: + if result.position_record_id != expected_position_record_id: raise PeopleMutationIntegrityError("position result identity does not match command") return result @@ -436,6 +438,7 @@ def create_assignment_record( if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") AssignmentMutationCommand.__post_init__(command) + expected_assignment_record_id = UUID(int=command.assignment_record_id.int) port = _require_port(mutation_port) authorization = authorize_resource_fields( principal=principal, @@ -452,7 +455,7 @@ def create_assignment_record( if type(result) is not AssignmentMutationResult: raise TypeError("mutation_port must return AssignmentMutationResult") AssignmentMutationResult.__post_init__(result) - if result.assignment_record_id != command.assignment_record_id: + if result.assignment_record_id != expected_assignment_record_id: raise PeopleMutationIntegrityError("assignment result identity does not match command") return result From 62a6b7ac2908dce2ea760c13123f8685a6f0eb5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:10:48 +0900 Subject: [PATCH 058/269] fix(people): compare hire result to detached targets --- services/people-api/src/orgmetra_people_api/hire.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index cb47905ba..16ad2c66d 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -150,6 +150,11 @@ def accept_confirmed_hire( if type(command) is not HireAcceptanceCommand: raise TypeError("command must be a HireAcceptanceCommand") HireAcceptanceCommand.__post_init__(command) + expected_person_record_id = UUID(int=command.person_record_id.int) + expected_employment_record_id = UUID(int=command.employment_record_id.int) + expected_conversion_record_id = UUID( + int=command.candidate_worker_conversion_record_id.int + ) if not isinstance(mutation_port, HireAcceptancePort): raise TypeError("mutation_port must implement HireAcceptancePort") @@ -169,9 +174,9 @@ def accept_confirmed_hire( raise TypeError("mutation_port must return HireAcceptanceResult") HireAcceptanceResult.__post_init__(result) if ( - result.person_record_id != command.person_record_id - or result.employment_record_id != command.employment_record_id - or result.candidate_worker_conversion_record_id != command.candidate_worker_conversion_record_id + result.person_record_id != expected_person_record_id + or result.employment_record_id != expected_employment_record_id + or result.candidate_worker_conversion_record_id != expected_conversion_record_id ): raise HireDecisionIntegrityError("hire result identity does not match command") return result From f7415084d801bc06be316b022f41f53763197882 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:02:29 +0900 Subject: [PATCH 059/269] test(people): bind mutation commands before authorization callbacks --- ...mutation_authorization_command_snapshot.py | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_authorization_command_snapshot.py diff --git a/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py b/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py new file mode 100644 index 000000000..d67c91b18 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py @@ -0,0 +1,274 @@ +"""Application command-snapshot regressions across purpose-bound authorization.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + AssignmentMutationResult, + EmploymentMutationCommand, + EmploymentMutationResult, + PositionMutationCommand, + PositionMutationResult, + create_assignment_record, + create_employment_record, + create_position_record, +) + +TENANT = UUID("0198a412-c100-7000-8000-000000000001") +PERSON = UUID("0198a412-c100-7000-8000-000000000010") +OTHER_PERSON = UUID("0198a412-c100-7000-8000-000000000011") +EMPLOYMENT = UUID("0198a412-c100-7000-8000-000000000020") +EMPLOYMENT_VERSION = UUID("0198a412-c100-7000-8000-000000000021") +ORGANIZATION = UUID("0198a412-c100-7000-8000-000000000030") +JOB = UUID("0198a412-c100-7000-8000-000000000040") +OTHER_JOB = UUID("0198a412-c100-7000-8000-000000000041") +POSITION = UUID("0198a412-c100-7000-8000-000000000050") +POSITION_VERSION = UUID("0198a412-c100-7000-8000-000000000051") +OTHER_POSITION = UUID("0198a412-c100-7000-8000-000000000052") +ASSIGNMENT = UUID("0198a412-c100-7000-8000-000000000060") +AUDIT_EVENT = UUID("0198a412-c100-7000-8000-000000000070") +OUTBOX = UUID("0198a412-c100-7000-8000-000000000071") +EFFECTIVE_FROM = date(2026, 9, 5) + +PRINCIPAL = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:authorization-snapshot-operator", + granted_scope_codes=frozenset({"orgmetra.people.write"}), +) + + +class _MutatingResourceKind(str): + """Rewrite a retained caller command when policy comparison executes.""" + + def __new__( + cls, + value: str, + *, + command: object, + field_name: str, + replacement: object, + ) -> _MutatingResourceKind: + """Retain the caller command solely for the adversarial comparison callback.""" + instance = super().__new__(cls, value) + instance.command = command + instance.field_name = field_name + instance.replacement = replacement + return instance + + def _mutate_command(self) -> None: + """Simulate caller-owned executable policy behavior during authorization.""" + object.__setattr__(self.command, self.field_name, self.replacement) + + def __eq__(self, other: object) -> bool: + """Mutate before preserving ordinary string equality semantics.""" + self._mutate_command() + return str.__eq__(self, other) + + def __ne__(self, other: object) -> bool: + """Mutate before preserving ordinary string inequality semantics.""" + self._mutate_command() + return str.__ne__(self, other) + + +class _CapturingMutationPort: + """Capture the semantic command that crosses the application port boundary.""" + + employment_command: EmploymentMutationCommand | None = None + position_command: PositionMutationCommand | None = None + assignment_command: AssignmentMutationCommand | None = None + + def create_employment( + self, + *, + command: EmploymentMutationCommand, + authorization: object, + ) -> EmploymentMutationResult: + """Capture Employment semantics and return the commanded target identity.""" + del authorization + self.employment_command = command + return EmploymentMutationResult(employment_record_id=command.employment_record_id) + + def create_position( + self, + *, + command: PositionMutationCommand, + authorization: object, + ) -> PositionMutationResult: + """Capture Position semantics and return the commanded target identity.""" + del authorization + self.position_command = command + return PositionMutationResult(position_record_id=command.position_record_id) + + def create_assignment( + self, + *, + command: AssignmentMutationCommand, + authorization: object, + ) -> AssignmentMutationResult: + """Capture Assignment semantics and return the commanded target identity.""" + del authorization + self.assignment_command = command + return AssignmentMutationResult(assignment_record_id=command.assignment_record_id) + + +def _policy( + *, + resource_kind: str, + field_name: str, + command: object, + command_field_name: str, + replacement: object, +) -> PurposeBoundAccessPolicy: + """Build a valid policy whose resource-kind comparison mutates caller state.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="authorization-snapshot-v1", + resource_kind=_MutatingResourceKind( + resource_kind, + command=command, + field_name=command_field_name, + replacement=replacement, + ), + purpose_code="workforce_admin", + operation_code="create_record", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({field_name}), + ) + + +def _employment_command() -> EmploymentMutationCommand: + """Build one valid Employment command for authorization interleaving.""" + return EmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:authorization-snapshot", + evidence_version_code="authorization-snapshot-v1", + idempotency_key="authorization-snapshot-employment-1", + ) + + +def _position_command() -> PositionMutationCommand: + """Build one valid Position command for authorization interleaving.""" + return PositionMutationCommand( + tenant_record_id=TENANT, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB, + position_record_id=POSITION, + position_record_version_id=POSITION_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + position_status_code="open", + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:authorization-snapshot", + evidence_version_code="authorization-snapshot-v1", + idempotency_key="authorization-snapshot-position-1", + ) + + +def _assignment_command() -> AssignmentMutationCommand: + """Build one valid Assignment command for authorization interleaving.""" + return AssignmentMutationCommand( + tenant_record_id=TENANT, + employment_record_id=EMPLOYMENT, + person_record_id=PERSON, + position_record_id=POSITION, + assignment_record_id=ASSIGNMENT, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + allocation_ratio=Decimal("0.5000"), + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:authorization-snapshot", + evidence_version_code="authorization-snapshot-v1", + idempotency_key="authorization-snapshot-assignment-1", + ) + + +class PeopleMutationAuthorizationCommandSnapshotTests(unittest.TestCase): + """Require authorization callbacks to see no caller-owned command authority.""" + + def test_employment_port_receives_pre_authorization_semantics(self) -> None: + """Policy execution may mutate caller state but not the Employment port command.""" + command = _employment_command() + port = _CapturingMutationPort() + create_employment_record( + principal=PRINCIPAL, + command=command, + purpose_code="workforce_admin", + policy=_policy( + resource_kind="employment_record", + field_name="employment_record", + command=command, + command_field_name="person_record_id", + replacement=OTHER_PERSON, + ), + mutation_port=port, + ) + self.assertEqual(command.person_record_id, OTHER_PERSON) + self.assertIsNotNone(port.employment_command) + assert port.employment_command is not None + self.assertEqual(port.employment_command.person_record_id, PERSON) + self.assertIsNot(port.employment_command, command) + + def test_position_port_receives_pre_authorization_semantics(self) -> None: + """Policy execution may mutate caller state but not the Position port command.""" + command = _position_command() + port = _CapturingMutationPort() + create_position_record( + principal=PRINCIPAL, + command=command, + purpose_code="workforce_admin", + policy=_policy( + resource_kind="position_record", + field_name="position_record", + command=command, + command_field_name="job_profile_id", + replacement=OTHER_JOB, + ), + mutation_port=port, + ) + self.assertEqual(command.job_profile_id, OTHER_JOB) + self.assertIsNotNone(port.position_command) + assert port.position_command is not None + self.assertEqual(port.position_command.job_profile_id, JOB) + self.assertIsNot(port.position_command, command) + + def test_assignment_port_receives_pre_authorization_semantics(self) -> None: + """Policy execution may mutate caller state but not the Assignment port command.""" + command = _assignment_command() + port = _CapturingMutationPort() + create_assignment_record( + principal=PRINCIPAL, + command=command, + purpose_code="workforce_admin", + policy=_policy( + resource_kind="assignment_record", + field_name="assignment_record", + command=command, + command_field_name="position_record_id", + replacement=OTHER_POSITION, + ), + mutation_port=port, + ) + self.assertEqual(command.position_record_id, OTHER_POSITION) + self.assertIsNotNone(port.assignment_command) + assert port.assignment_command is not None + self.assertEqual(port.assignment_command.position_record_id, POSITION) + self.assertIsNot(port.assignment_command, command) + + +if __name__ == "__main__": + unittest.main() From e4d538c3e5c0707b650e4eeefb7d91dc4611fe9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:03:44 +0900 Subject: [PATCH 060/269] fix(people): detach commands before authorization callbacks --- services/people-api/src/orgmetra_people_api/mutations.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 04448298d..bdc68d658 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -10,7 +10,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date from decimal import Decimal from hashlib import sha256 @@ -369,7 +369,7 @@ def create_employment_record( """Authorize the exact employment target before persisting worker employment truth.""" if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") - EmploymentMutationCommand.__post_init__(command) + command = replace(command) expected_employment_record_id = UUID(int=command.employment_record_id.int) port = _require_port(mutation_port) authorization = authorize_resource_fields( @@ -403,7 +403,7 @@ def create_position_record( """Authorize the exact position target before persisting a staffable seat.""" if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") - PositionMutationCommand.__post_init__(command) + command = replace(command) expected_position_record_id = UUID(int=command.position_record_id.int) port = _require_port(mutation_port) authorization = authorize_resource_fields( @@ -437,7 +437,7 @@ def create_assignment_record( """Authorize the exact assignment target before persisting seat allocation.""" if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") - AssignmentMutationCommand.__post_init__(command) + command = replace(command) expected_assignment_record_id = UUID(int=command.assignment_record_id.int) port = _require_port(mutation_port) authorization = authorize_resource_fields( From 5a6354a94a4098509d68a6d413c0dbe8b236f94f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:09:24 +0900 Subject: [PATCH 061/269] test(people): bind hire command before authorization callbacks --- ...est_hire_authorization_command_snapshot.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 services/people-api/tests/test_hire_authorization_command_snapshot.py diff --git a/services/people-api/tests/test_hire_authorization_command_snapshot.py b/services/people-api/tests/test_hire_authorization_command_snapshot.py new file mode 100644 index 000000000..30804e05f --- /dev/null +++ b/services/people-api/tests/test_hire_authorization_command_snapshot.py @@ -0,0 +1,147 @@ +"""Application command-snapshot regression for confirmed-hire authorization.""" + +from __future__ import annotations + +from datetime import date +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import ( + HireAcceptanceCommand, + HireAcceptanceResult, + accept_confirmed_hire, +) + +TENANT = UUID("0198a412-c200-7000-8000-000000000001") +CANDIDATE = UUID("0198a412-c200-7000-8000-000000000010") +SELECTION_DECISION = UUID("0198a412-c200-7000-8000-000000000011") +PERSON = UUID("0198a412-c200-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-c200-7000-8000-000000000021") +EMPLOYMENT = UUID("0198a412-c200-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-c200-7000-8000-000000000031") +CONVERSION = UUID("0198a412-c200-7000-8000-000000000040") +AUDIT_EVENT = UUID("0198a412-c200-7000-8000-000000000050") +OUTBOX = UUID("0198a412-c200-7000-8000-000000000051") +EFFECTIVE_FROM = date(2026, 9, 5) +ORIGINAL_DISPLAY_NAME = "Authorization Snapshot Worker" +MUTATED_DISPLAY_NAME = "Authorization Callback Rewrite" + +PRINCIPAL = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:hire-authorization-snapshot-operator", + granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), +) + + +class _MutatingResourceKind(str): + """Rewrite retained caller hire data when policy comparison executes.""" + + def __new__( + cls, + value: str, + *, + command: HireAcceptanceCommand, + ) -> _MutatingResourceKind: + """Retain the caller command solely for the adversarial comparison callback.""" + instance = super().__new__(cls, value) + instance.command = command + return instance + + def _mutate_command(self) -> None: + """Rewrite valid PII after application validation but during authorization.""" + object.__setattr__(self.command, "display_name", MUTATED_DISPLAY_NAME) + + def __eq__(self, other: object) -> bool: + """Mutate before preserving ordinary string equality semantics.""" + self._mutate_command() + return str.__eq__(self, other) + + def __ne__(self, other: object) -> bool: + """Mutate before preserving ordinary string inequality semantics.""" + self._mutate_command() + return str.__ne__(self, other) + + +class _CapturingHirePort: + """Capture the hire command that crosses the application port boundary.""" + + command: HireAcceptanceCommand | None = None + + def accept_hire( + self, + *, + command: HireAcceptanceCommand, + authorization: object, + ) -> HireAcceptanceResult: + """Capture hire semantics and return the commanded authoritative identities.""" + del authorization + self.command = command + return HireAcceptanceResult( + person_record_id=command.person_record_id, + employment_record_id=command.employment_record_id, + candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, + ) + + +def _command() -> HireAcceptanceCommand: + """Build one valid confirmed-hire command for authorization interleaving.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=SELECTION_DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + effective_from=EFFECTIVE_FROM, + display_name=ORIGINAL_DISPLAY_NAME, + idempotency_key="hire-authorization-snapshot-1", + ) + + +def _policy(command: HireAcceptanceCommand) -> PurposeBoundAccessPolicy: + """Build a valid policy whose resource-kind comparison mutates caller state.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="hire-authorization-snapshot-v1", + resource_kind=_MutatingResourceKind("selection_decision", command=command), + purpose_code="candidate_hire", + operation_code="materialize_worker", + required_scope_code="orgmetra.people.materialize_worker", + permitted_fields=frozenset({"candidate_worker_conversion"}), + ) + + +class HireAuthorizationCommandSnapshotTests(unittest.TestCase): + """Require authorization callbacks to have no authority over the port hire command.""" + + def test_hire_port_receives_pre_authorization_semantics(self) -> None: + """Policy execution may mutate caller PII but not the detached port command.""" + command = _command() + port = _CapturingHirePort() + + result = accept_confirmed_hire( + principal=PRINCIPAL, + command=command, + purpose_code="candidate_hire", + policy=_policy(command), + mutation_port=port, + ) + + self.assertEqual(command.display_name, MUTATED_DISPLAY_NAME) + self.assertIsNotNone(port.command) + assert port.command is not None + self.assertEqual(port.command.display_name, ORIGINAL_DISPLAY_NAME) + self.assertIsNot(port.command, command) + self.assertEqual(result.person_record_id, PERSON) + self.assertEqual(result.employment_record_id, EMPLOYMENT) + self.assertEqual(result.candidate_worker_conversion_record_id, CONVERSION) + + +if __name__ == "__main__": + unittest.main() From 9771be6d65bef77408cd5ad1ae316f0a8d8fb5e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 13:09:41 +0900 Subject: [PATCH 062/269] fix(people): detach hire command before authorization callbacks --- services/people-api/src/orgmetra_people_api/hire.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 16ad2c66d..86ff5a625 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -8,7 +8,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import date import re from typing import Protocol, runtime_checkable @@ -149,7 +149,7 @@ def accept_confirmed_hire( """ if type(command) is not HireAcceptanceCommand: raise TypeError("command must be a HireAcceptanceCommand") - HireAcceptanceCommand.__post_init__(command) + command = replace(command) expected_person_record_id = UUID(int=command.person_record_id.int) expected_employment_record_id = UUID(int=command.employment_record_id.int) expected_conversion_record_id = UUID( From 6fcb1c45173b6d49d14f0dd34d0f986e28b9d256 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:06:41 +0900 Subject: [PATCH 063/269] test(people): reject allocation text runtime subtype --- ...utation_allocation_text_runtime_integrity.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py diff --git a/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py b/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py new file mode 100644 index 000000000..6cc1af0e3 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py @@ -0,0 +1,17 @@ +"""Reject non-canonical allocation-ratio text before Decimal parsing.""" + +from __future__ import annotations + +import pytest + +from orgmetra_people_api.mutations import parse_allocation_ratio + + +class _AllocationRatioText(str): + """Represent a valid-looking allocation token with caller-defined runtime identity.""" + + +def test_parse_allocation_ratio_rejects_string_subclasses() -> None: + """Assignment allocation text must be the exact built-in value that was parsed.""" + with pytest.raises(ValueError, match="allocation_ratio"): + parse_allocation_ratio(_AllocationRatioText("0.2500")) From 7779a852b4bdd4fb281f6813a42960c425e361f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:07:57 +0900 Subject: [PATCH 064/269] fix(people): require exact allocation text --- services/people-api/src/orgmetra_people_api/mutations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index bdc68d658..69d825f24 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -462,6 +462,6 @@ def create_assignment_record( def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" - if not isinstance(raw_value, str) or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: + if type(raw_value) is not str or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) + return Decimal(raw_value) \ No newline at end of file From 8d3877650865f71c4d9708b06eb55f43a7cb7661 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:09:04 +0900 Subject: [PATCH 065/269] style(people): preserve source trailing newline --- services/people-api/src/orgmetra_people_api/mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 69d825f24..7ada72d37 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -464,4 +464,4 @@ def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" if type(raw_value) is not str or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) \ No newline at end of file + return Decimal(raw_value) From abd5dc506d363897d0396dc9e75b1e389b47c71f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:24:07 +0900 Subject: [PATCH 066/269] test(people): expose zero allocation contract split --- ...ation_allocation_text_runtime_integrity.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py b/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py index 6cc1af0e3..f2f238356 100644 --- a/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py +++ b/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py @@ -2,16 +2,46 @@ from __future__ import annotations +from pathlib import Path +import re + import pytest from orgmetra_people_api.mutations import parse_allocation_ratio +_OPENAPI_PATH = Path(__file__).resolve().parents[3] / "schemas" / "openapi.yaml" + class _AllocationRatioText(str): """Represent a valid-looking allocation token with caller-defined runtime identity.""" +def _published_allocation_pattern() -> re.Pattern[str]: + """Read the Assignment allocation token pattern from the published OpenAPI contract.""" + schema = _OPENAPI_PATH.read_text(encoding="utf-8") + match = re.search( + r"allocation_ratio:\n\s+type: string\n\s+pattern: '([^']+)'", + schema, + ) + assert match is not None, "CreateAssignmentRecordCommand allocation pattern is missing" + return re.compile(match.group(1)) + + def test_parse_allocation_ratio_rejects_string_subclasses() -> None: """Assignment allocation text must be the exact built-in value that was parsed.""" with pytest.raises(ValueError, match="allocation_ratio"): parse_allocation_ratio(_AllocationRatioText("0.2500")) + + +def test_parse_allocation_ratio_rejects_zero_before_domain_construction() -> None: + """The HTTP scalar parser must enforce the same strictly-positive Assignment invariant.""" + with pytest.raises(ValueError, match="allocation_ratio"): + parse_allocation_ratio("0.0000") + + +def test_openapi_allocation_pattern_matches_the_strictly_positive_domain_range() -> None: + """Generated clients and handlers must not advertise zero as a valid Assignment ratio.""" + pattern = _published_allocation_pattern() + assert pattern.fullmatch("0.0000") is None + for token in ("0.0001", "0.2500", "0.9999", "1.0000"): + assert pattern.fullmatch(token) is not None From 7a95aa95a6a9cf4497b301ffee0748555d98776e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:27:22 +0900 Subject: [PATCH 067/269] fix(people): reject zero allocation before domain construction --- services/people-api/src/orgmetra_people_api/mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 7ada72d37..d90bc6773 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -462,6 +462,6 @@ def create_assignment_record( def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" - if type(raw_value) is not str or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: + if type(raw_value) is not str or re.fullmatch(r"^(0\.(?!0000)[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") return Decimal(raw_value) From f659b652fbe307134792370ac273514f0b1830a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:34:38 +0900 Subject: [PATCH 068/269] fix(api): align assignment allocation contract with domain invariant --- schemas/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 0fd397e92..c03ffab0b 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -641,7 +641,7 @@ components: format: uuid allocation_ratio: type: string - pattern: '^(0\.[0-9]{4}|1\.0000)$' + pattern: '^(0\.(?!0000)[0-9]{4}|1\.0000)$' effective_from: type: string format: date From da4b628fe344372ec22421cfa010e15105ad2c50 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:36:06 +0900 Subject: [PATCH 069/269] chore(manifest): reseal updated OpenAPI allocation contract --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 956e51d26..42d438d5e 100644 --- a/manifest.json +++ b/manifest.json @@ -83,7 +83,7 @@ }, { "path": "database/migrations/0005_outbox_delivery_finalization.sql", - "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", + "sha256": "b7e8790595b288f7525d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", "bytes": 6125, "lines": 170 }, @@ -353,8 +353,8 @@ }, { "path": "schemas/openapi.yaml", - "sha256": "09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f", - "bytes": 29503, + "sha256": "c37522504d1f6ac6410eaac833dddbf09aacc85572da38a1cf7539541833ea8e", + "bytes": 29511, "lines": 1020 }, { From 8f986853a6f234c317e29080c4982bab34f3dc51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:37:55 +0900 Subject: [PATCH 070/269] fix(manifest): restore unaffected outbox digest --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 42d438d5e..ac1648354 100644 --- a/manifest.json +++ b/manifest.json @@ -83,7 +83,7 @@ }, { "path": "database/migrations/0005_outbox_delivery_finalization.sql", - "sha256": "b7e8790595b288f7525d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", + "sha256": "b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961", "bytes": 6125, "lines": 170 }, From d7440d40e46f59a2596167bbca54af2a79e901a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:38:05 +0900 Subject: [PATCH 071/269] test(people): specify idempotent replay result evidence --- ...eople_mutation_idempotent_replay_result.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_idempotent_replay_result.py diff --git a/services/people-api/tests/test_people_mutation_idempotent_replay_result.py b/services/people-api/tests/test_people_mutation_idempotent_replay_result.py new file mode 100644 index 000000000..093c61960 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_idempotent_replay_result.py @@ -0,0 +1,119 @@ +"""Result-receipt regressions for idempotent generic People mutation replay.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + AssignmentMutationResult, + EmploymentMutationCommand, + EmploymentMutationResult, + PeopleMutationIntegrityError, + PositionMutationCommand, + PositionMutationResult, + create_assignment_record, + create_employment_record, + create_position_record, + mutation_command_digest, +) +from test_people_mutations import ( + ASSIGNMENT, + EMPLOYMENT, + POSITION, + PRINCIPAL, + assignment_command, + assignment_policy, + employment_command, + employment_policy, + position_command, + position_policy, +) + +NEW_EMPLOYMENT = UUID("0198a412-8200-7000-8000-000000000033") +NEW_POSITION = UUID("0198a412-8200-7000-8000-000000000044") +NEW_ASSIGNMENT = UUID("0198a412-8200-7000-8000-000000000077") + + +class ReplayReceiptPort: + """Return first-committed identities with independently checkable replay evidence.""" + + def __init__(self, *, digest_override: str | None = None) -> None: + self.digest_override = digest_override + + def _digest(self, *, command: object, authorization: object) -> str: + digest = mutation_command_digest(command=command, authorization=authorization) # type: ignore[arg-type] + return self.digest_override if self.digest_override is not None else digest + + def create_employment(self, *, command: EmploymentMutationCommand, authorization: object) -> EmploymentMutationResult: + return EmploymentMutationResult( + employment_record_id=EMPLOYMENT, + replay_command_digest=self._digest(command=command, authorization=authorization), + ) + + def create_position(self, *, command: PositionMutationCommand, authorization: object) -> PositionMutationResult: + return PositionMutationResult( + position_record_id=POSITION, + replay_command_digest=self._digest(command=command, authorization=authorization), + ) + + def create_assignment(self, *, command: AssignmentMutationCommand, authorization: object) -> AssignmentMutationResult: + return AssignmentMutationResult( + assignment_record_id=ASSIGNMENT, + replay_command_digest=self._digest(command=command, authorization=authorization), + ) + + +class PeopleMutationIdempotentReplayResultTests(unittest.TestCase): + """Reconcile first-committed replay identity with result-integrity hardening.""" + + def test_matching_replay_receipt_may_return_first_committed_identity(self) -> None: + port = ReplayReceiptPort() + + employment = create_employment_record( + principal=PRINCIPAL, + command=employment_command(employment_record_id=NEW_EMPLOYMENT), + purpose_code="workforce_admin", + policy=employment_policy(), + mutation_port=port, + ) + position = create_position_record( + principal=PRINCIPAL, + command=position_command(position_record_id=NEW_POSITION), + purpose_code="job_architecture_admin", + policy=position_policy(), + mutation_port=port, + ) + assignment = create_assignment_record( + principal=PRINCIPAL, + command=assignment_command(assignment_record_id=NEW_ASSIGNMENT), + purpose_code="workforce_admin", + policy=assignment_policy(), + mutation_port=port, + ) + + self.assertEqual(employment.employment_record_id, EMPLOYMENT) + self.assertEqual(position.position_record_id, POSITION) + self.assertEqual(assignment.assignment_record_id, ASSIGNMENT) + + def test_foreign_identity_with_mismatched_replay_digest_fails_closed(self) -> None: + with self.assertRaisesRegex(PeopleMutationIntegrityError, "replay evidence"): + create_employment_record( + principal=PRINCIPAL, + command=employment_command(employment_record_id=NEW_EMPLOYMENT), + purpose_code="workforce_admin", + policy=employment_policy(), + mutation_port=ReplayReceiptPort(digest_override="0" * 64), + ) + + def test_replay_digest_must_be_an_exact_string(self) -> None: + with self.assertRaisesRegex(ValueError, "replay_command_digest"): + EmploymentMutationResult( + employment_record_id=EMPLOYMENT, + replay_command_digest=object(), # type: ignore[arg-type] + ) + + +if __name__ == "__main__": + unittest.main() From cc1cc53f34908178495ff657930c3950ce5931f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:39:16 +0900 Subject: [PATCH 072/269] fix(people): bind foreign replay identity to semantic digest --- .../src/orgmetra_people_api/mutations.py | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index d90bc6773..776c80955 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -289,37 +289,49 @@ def __post_init__(self) -> None: validate_idempotency_key(self.idempotency_key) +def _validate_replay_command_digest(value: object) -> None: + """Require exact inert replay evidence when a mutation result carries it.""" + if value is not None and type(value) is not str: + raise ValueError("replay_command_digest must be an exact string when present.") + + @dataclass(frozen=True, slots=True) class EmploymentMutationResult: - """Opaque identity returned after one committed employment mutation.""" + """Opaque identity and optional verified-replay evidence for one employment mutation.""" employment_record_id: UUID + replay_command_digest: str | None = None def __post_init__(self) -> None: """Prevent malformed persistence results from crossing the service boundary.""" _validate_operational_uuid("employment_record_id", self.employment_record_id) + _validate_replay_command_digest(self.replay_command_digest) @dataclass(frozen=True, slots=True) class PositionMutationResult: - """Opaque identity returned after one committed position mutation.""" + """Opaque identity and optional verified-replay evidence for one position mutation.""" position_record_id: UUID + replay_command_digest: str | None = None def __post_init__(self) -> None: """Prevent malformed persistence results from crossing the service boundary.""" _validate_operational_uuid("position_record_id", self.position_record_id) + _validate_replay_command_digest(self.replay_command_digest) @dataclass(frozen=True, slots=True) class AssignmentMutationResult: - """Opaque identity returned after one committed assignment mutation.""" + """Opaque identity and optional verified-replay evidence for one assignment mutation.""" assignment_record_id: UUID + replay_command_digest: str | None = None def __post_init__(self) -> None: """Prevent malformed persistence results from crossing the service boundary.""" _validate_operational_uuid("assignment_record_id", self.assignment_record_id) + _validate_replay_command_digest(self.replay_command_digest) @runtime_checkable @@ -358,6 +370,24 @@ def _require_port(mutation_port: object) -> PeopleMutationPort: return mutation_port +def _require_result_identity_or_replay( + *, + result_record_id: UUID, + expected_record_id: UUID, + replay_command_digest: str | None, + command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, + authorization: AuthorizationDecision, + result_name: str, +) -> None: + """Accept a foreign identity only with replay evidence bound to this semantic command.""" + if replay_command_digest is not None: + if replay_command_digest != mutation_command_digest(command=command, authorization=authorization): + raise PeopleMutationIntegrityError(f"{result_name} replay evidence does not match command") + return + if result_record_id != expected_record_id: + raise PeopleMutationIntegrityError(f"{result_name} result identity does not match command") + + def create_employment_record( *, principal: AuthenticatedPrincipal, @@ -387,8 +417,14 @@ def create_employment_record( if type(result) is not EmploymentMutationResult: raise TypeError("mutation_port must return EmploymentMutationResult") EmploymentMutationResult.__post_init__(result) - if result.employment_record_id != expected_employment_record_id: - raise PeopleMutationIntegrityError("employment result identity does not match command") + _require_result_identity_or_replay( + result_record_id=result.employment_record_id, + expected_record_id=expected_employment_record_id, + replay_command_digest=result.replay_command_digest, + command=command, + authorization=authorization, + result_name="employment", + ) return result @@ -421,8 +457,14 @@ def create_position_record( if type(result) is not PositionMutationResult: raise TypeError("mutation_port must return PositionMutationResult") PositionMutationResult.__post_init__(result) - if result.position_record_id != expected_position_record_id: - raise PeopleMutationIntegrityError("position result identity does not match command") + _require_result_identity_or_replay( + result_record_id=result.position_record_id, + expected_record_id=expected_position_record_id, + replay_command_digest=result.replay_command_digest, + command=command, + authorization=authorization, + result_name="position", + ) return result @@ -455,8 +497,14 @@ def create_assignment_record( if type(result) is not AssignmentMutationResult: raise TypeError("mutation_port must return AssignmentMutationResult") AssignmentMutationResult.__post_init__(result) - if result.assignment_record_id != expected_assignment_record_id: - raise PeopleMutationIntegrityError("assignment result identity does not match command") + _require_result_identity_or_replay( + result_record_id=result.assignment_record_id, + expected_record_id=expected_assignment_record_id, + replay_command_digest=result.replay_command_digest, + command=command, + authorization=authorization, + result_name="assignment", + ) return result From a61617f0cdd6f1a2e29b512b2a3c66872af5ccc4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 20:41:34 +0900 Subject: [PATCH 073/269] fix(people): return verified idempotency replay receipt --- .../orgmetra_people_api/postgres_mutations.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index ede1deefd..c2a5cef3f 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -288,8 +288,8 @@ def _replayed_record_id( *, command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, authorization: AuthorizationDecision, -) -> UUID | None: - """Serialize one key, then return its committed record identity when present.""" +) -> tuple[UUID, str] | None: + """Serialize one key and return its committed identity plus verified semantic digest.""" route = command_route(command) digest = mutation_command_digest(command=command, authorization=authorization) key_parameters = (command.tenant_record_id, route, command.idempotency_key) @@ -310,7 +310,7 @@ def _replayed_record_id( if stored_digest != digest: raise PeopleMutationIntegrityError("idempotency key is bound to a different command") assert isinstance(created_record_id, UUID) - return created_record_id + return created_record_id, stored_digest def _record_idempotency( @@ -585,7 +585,11 @@ def create_employment( cursor.execute(_TENANT_CONTEXT_SQL, (str(command.tenant_record_id),)) replayed = _replayed_record_id(cursor, command=command, authorization=decision) if replayed is not None: - return EmploymentMutationResult(employment_record_id=replayed) + replayed_record_id, replay_digest = replayed + return EmploymentMutationResult( + employment_record_id=replayed_record_id, + replay_command_digest=replay_digest, + ) cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) _require_one_conversion(cursor.fetchmany(2)) recorded_at = _post_lock_recorded_at(cursor) @@ -694,7 +698,11 @@ def create_position( cursor.execute(_TENANT_CONTEXT_SQL, (str(command.tenant_record_id),)) replayed = _replayed_record_id(cursor, command=command, authorization=decision) if replayed is not None: - return PositionMutationResult(position_record_id=replayed) + replayed_record_id, replay_digest = replayed + return PositionMutationResult( + position_record_id=replayed_record_id, + replay_command_digest=replay_digest, + ) cursor.execute( _POSITION_PARENTS_SQL, (command.job_profile_id, command.tenant_record_id, command.organization_unit_id), @@ -791,7 +799,11 @@ def create_assignment( cursor.execute(_TENANT_CONTEXT_SQL, (str(command.tenant_record_id),)) replayed = _replayed_record_id(cursor, command=command, authorization=decision) if replayed is not None: - return AssignmentMutationResult(assignment_record_id=replayed) + replayed_record_id, replay_digest = replayed + return AssignmentMutationResult( + assignment_record_id=replayed_record_id, + replay_command_digest=replay_digest, + ) cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) _require_one_conversion(cursor.fetchmany(2)) cursor.execute( From 3f3b23a35a71f4fdaded3cb0cd1ee57412a7df05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:13:45 +0900 Subject: [PATCH 074/269] test(people): cover fixed projection shape guards --- ...gres_mutation_projection_shape_coverage.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 services/people-api/tests/test_postgres_mutation_projection_shape_coverage.py diff --git a/services/people-api/tests/test_postgres_mutation_projection_shape_coverage.py b/services/people-api/tests/test_postgres_mutation_projection_shape_coverage.py new file mode 100644 index 000000000..c8e9e49a2 --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_projection_shape_coverage.py @@ -0,0 +1,27 @@ +"""Hosted-coverage regressions for fixed People PostgreSQL projection widths.""" + +from __future__ import annotations + +import pytest + +import orgmetra_people_api.postgres_mutations as postgres_mutations +from orgmetra_people_api.mutations import PeopleMutationIntegrityError + + +@pytest.mark.parametrize( + ("helper_name", "error_message"), + [ + ("_employment_version_from_row", "employment version row has an invalid shape"), + ("_position_version_from_row", "position version row has an invalid shape"), + ("_assignment_from_row", "assignment row has an invalid shape"), + ], +) +def test_fixed_projection_helpers_reject_wrong_width( + helper_name: str, + error_message: str, +) -> None: + """Cover each fail-closed width guard reported missing by exact-head Foundation CI.""" + helper = getattr(postgres_mutations, helper_name) + + with pytest.raises(PeopleMutationIntegrityError, match=error_message): + helper(postgres_mutations.UUID("10000000-0000-7000-8000-000000000001"), ()) From f933fbc2ce9b6d6d152d8046866553a757ff89f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:18:10 +0900 Subject: [PATCH 075/269] test(people): use standard UUID attribute trap --- .../people-api/tests/test_hire_post_construction_integrity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/tests/test_hire_post_construction_integrity.py b/services/people-api/tests/test_hire_post_construction_integrity.py index d22cdac8a..3ba432939 100644 --- a/services/people-api/tests/test_hire_post_construction_integrity.py +++ b/services/people-api/tests/test_hire_post_construction_integrity.py @@ -29,7 +29,7 @@ class _ExecutableUUID(UUID): def __getattribute__(self, name: str) -> object: """Fail if a rewritten UUID is rendered before command revalidation.""" if name == "hex": - raise AssertionError("UUID subtype behavior executed before command revalidation") + raise AttributeError("UUID subtype behavior executed before command revalidation") return super().__getattribute__(name) From 4e9e04fb3d4730f94affeb67c90d5396622de23c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:18:52 +0900 Subject: [PATCH 076/269] test(people): use standard row-container trap errors --- .../test_postgres_hire_row_container_runtime_integrity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py index c084f9c48..a58ebfca0 100644 --- a/services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_hire_row_container_runtime_integrity.py @@ -34,7 +34,7 @@ class _ExecutableBatch(list[object]): def __bool__(self) -> bool: """Reject pre-gate truthiness.""" - raise AssertionError("row collection truthiness executed before exact-type validation") + raise TypeError("row collection truthiness executed before exact-type validation") def __len__(self) -> int: """Reject pre-gate length inspection.""" @@ -43,7 +43,7 @@ def __len__(self) -> int: def __getitem__(self, key: object) -> object: """Reject pre-gate indexed access.""" del key - raise AssertionError("row collection indexing executed before exact-type validation") + raise IndexError("row collection indexing executed before exact-type validation") def __iter__(self): """Reject pre-gate row iteration.""" @@ -60,7 +60,7 @@ def __len__(self) -> int: def __getitem__(self, key: object) -> object: """Reject pre-gate row indexing.""" del key - raise AssertionError("row indexing executed before exact-type validation") + raise IndexError("row indexing executed before exact-type validation") def __iter__(self): """Reject pre-gate row iteration.""" From 7485cb2856b3b119a5eaba141fb0ee958c8fae9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:19:01 +0900 Subject: [PATCH 077/269] test(people): use standard durable UUID trap --- .../tests/test_postgres_hire_uuid_runtime_integrity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py b/services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py index 7b12d98ad..be18c536c 100644 --- a/services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_hire_uuid_runtime_integrity.py @@ -14,7 +14,7 @@ class _ExecutableUUID(UUID): def __getattribute__(self, name: str) -> object: """Fail when untrusted UUID evidence is inspected as if it were inert.""" if name == "int": - raise AssertionError("UUID subtype behavior executed before exact-type validation") + raise AttributeError("UUID subtype behavior executed before exact-type validation") return super().__getattribute__(name) From 59f6eae362d37f57e37807f39478c03a3294a93a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:19:18 +0900 Subject: [PATCH 078/269] test(people): use standard projection-container trap errors --- .../tests/test_postgres_mutation_row_container_integrity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_postgres_mutation_row_container_integrity.py b/services/people-api/tests/test_postgres_mutation_row_container_integrity.py index fc7b77fe3..11545ca7c 100644 --- a/services/people-api/tests/test_postgres_mutation_row_container_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_row_container_integrity.py @@ -16,7 +16,7 @@ class _ExecutableRows(list[object]): def __bool__(self) -> bool: """Fail if durable validation asks this untrusted collection for truthiness.""" type(self).calls += 1 - raise AssertionError("outer durable row collection executed __bool__") + raise TypeError("outer durable row collection executed __bool__") def __len__(self) -> int: """Fail if durable validation asks this untrusted collection for cardinality.""" @@ -26,7 +26,7 @@ def __len__(self) -> int: def __getitem__(self, index: object) -> object: """Fail if durable validation indexes this untrusted collection.""" type(self).calls += 1 - raise AssertionError("outer durable row collection executed __getitem__") + raise IndexError("outer durable row collection executed __getitem__") def __iter__(self): """Fail if durable validation iterates this untrusted collection.""" From 998e06f49e5f91d84b335992761f2c210ea0ec39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 00:20:00 +0900 Subject: [PATCH 079/269] test(people): use standard scalar tripwire protocols --- ...test_postgres_mutation_scalar_runtime_integrity.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py index 035a467a1..57760565e 100644 --- a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py @@ -28,7 +28,7 @@ class _ExecutableUUID(UUID): def __getattribute__(self, name: str) -> object: """Fail when untrusted UUID evidence is inspected as if it were inert.""" if name == "int": - raise AssertionError("UUID subtype behavior executed before exact-type validation") + raise AttributeError("UUID subtype behavior executed before exact-type validation") return super().__getattribute__(name) @@ -85,10 +85,7 @@ def __new__(cls, value: str) -> _ExecutableStatusText: instance.calls = 0 return instance - def __hash__(self) -> int: - """Fail if HRIS validation hashes persisted subtype text.""" - self.calls += 1 - raise AssertionError("status subtype hashing executed before exact-type validation") + __hash__ = None def __eq__(self, other: object) -> bool: """Fail if HRIS validation compares persisted subtype text.""" @@ -116,13 +113,13 @@ def __gt__(self, other: object) -> bool: """Fail if FTE validation compares persisted subtype allocation.""" del other self.calls += 1 - raise AssertionError("Decimal subtype comparison executed before exact-type validation") + raise TypeError("Decimal subtype comparison executed before exact-type validation") def __le__(self, other: object) -> bool: """Fail if FTE validation compares persisted subtype allocation.""" del other self.calls += 1 - raise AssertionError("Decimal subtype comparison executed before exact-type validation") + raise TypeError("Decimal subtype comparison executed before exact-type validation") def __add__(self, other: object) -> Decimal: """Fail if portfolio aggregation adds persisted subtype allocation.""" From 4be7f1681959e43d32c8e85a8f2660da36ff6d9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:27:48 +0900 Subject: [PATCH 080/269] test(people): use numeric protocol errors for Decimal tripwires --- .../tests/test_postgres_mutation_scalar_runtime_integrity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py index 57760565e..5de9cbb1d 100644 --- a/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py @@ -125,13 +125,13 @@ def __add__(self, other: object) -> Decimal: """Fail if portfolio aggregation adds persisted subtype allocation.""" del other self.calls += 1 - raise AssertionError("Decimal subtype addition executed before exact-type validation") + raise TypeError("Decimal subtype addition executed before exact-type validation") def __radd__(self, other: object) -> Decimal: """Fail if portfolio aggregation reverse-adds persisted subtype allocation.""" del other self.calls += 1 - raise AssertionError("Decimal subtype reverse addition executed before exact-type validation") + raise TypeError("Decimal subtype reverse addition executed before exact-type validation") class _ReplayCursor: From 9de94ba277bc3f5984e8c342958bc40ab227409c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:03:28 +0900 Subject: [PATCH 081/269] test(people): expose stale internal package pins --- .../test_internal_dependency_versions.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 services/people-api/tests/test_internal_dependency_versions.py diff --git a/services/people-api/tests/test_internal_dependency_versions.py b/services/people-api/tests/test_internal_dependency_versions.py new file mode 100644 index 000000000..50e7a4e1c --- /dev/null +++ b/services/people-api/tests/test_internal_dependency_versions.py @@ -0,0 +1,29 @@ +"""Keep People API package metadata aligned with canonical owned package versions.""" + +from pathlib import Path +import tomllib + + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] + + +def _project_metadata(relative_path: str) -> dict[str, object]: + """Read project metadata without importing or executing package code.""" + pyproject_path = _REPOSITORY_ROOT / relative_path / "pyproject.toml" + with pyproject_path.open("rb") as pyproject_file: + return tomllib.load(pyproject_file)["project"] + + +def test_people_api_internal_dependencies_match_owned_package_versions() -> None: + """Reject stale internal distribution pins hidden by source-tree PYTHONPATH tests.""" + people_project = _project_metadata("services/people-api") + declared_dependencies = set(people_project["dependencies"]) + + expected_dependencies = set() + for package_path in ("packages/hris-kernel", "packages/keyverse-adapter"): + package_project = _project_metadata(package_path) + expected_dependencies.add( + f"{package_project['name']}=={package_project['version']}" + ) + + assert expected_dependencies <= declared_dependencies From 358152d4fd4393eacacc4fb94d119d31fb56bf98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:03:38 +0900 Subject: [PATCH 082/269] fix(people): align HRIS kernel package dependency --- services/people-api/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/pyproject.toml b/services/people-api/pyproject.toml index 3f228e11c..994309e73 100644 --- a/services/people-api/pyproject.toml +++ b/services/people-api/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.11" license = { text = "Apache-2.0" } authors = [{ name = "ContextualWisdomLab" }] dependencies = [ - "orgmetra-hris-kernel==0.1.0", + "orgmetra-hris-kernel==0.4.0", "orgmetra-keyverse-adapter==0.1.0", ] From f488fef3975aa3c946b5bef76cdbd7a3b232382c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:07:50 +0900 Subject: [PATCH 083/269] test(people): reject impossible Python runtime metadata --- .../test_internal_dependency_versions.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/services/people-api/tests/test_internal_dependency_versions.py b/services/people-api/tests/test_internal_dependency_versions.py index 50e7a4e1c..67d497360 100644 --- a/services/people-api/tests/test_internal_dependency_versions.py +++ b/services/people-api/tests/test_internal_dependency_versions.py @@ -1,6 +1,7 @@ """Keep People API package metadata aligned with canonical owned package versions.""" from pathlib import Path +import re import tomllib @@ -14,6 +15,15 @@ def _project_metadata(relative_path: str) -> dict[str, object]: return tomllib.load(pyproject_file)["project"] +def _minimum_python_version(project: dict[str, object]) -> tuple[int, int]: + """Return the exact declared lower Python minor for the current simple contract.""" + requires_python = project.get("requires-python") + assert isinstance(requires_python, str) + match = re.fullmatch(r">=(\d+)\.(\d+)", requires_python) + assert match is not None, "requires-python must remain an explicit >=major.minor floor" + return int(match.group(1)), int(match.group(2)) + + def test_people_api_internal_dependencies_match_owned_package_versions() -> None: """Reject stale internal distribution pins hidden by source-tree PYTHONPATH tests.""" people_project = _project_metadata("services/people-api") @@ -27,3 +37,13 @@ def test_people_api_internal_dependencies_match_owned_package_versions() -> None ) assert expected_dependencies <= declared_dependencies + + +def test_people_api_python_floor_covers_owned_runtime_dependencies() -> None: + """Reject a service Python floor that cannot install its mandatory owned packages.""" + people_project = _project_metadata("services/people-api") + people_floor = _minimum_python_version(people_project) + + for package_path in ("packages/hris-kernel", "packages/keyverse-adapter"): + package_project = _project_metadata(package_path) + assert _minimum_python_version(package_project) <= people_floor From a605749a147f3a374d1b1f60b915e46956719ca4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 09:08:03 +0900 Subject: [PATCH 084/269] fix(people): align Python floor with owned dependencies --- services/people-api/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/pyproject.toml b/services/people-api/pyproject.toml index 994309e73..af2e018d0 100644 --- a/services/people-api/pyproject.toml +++ b/services/people-api/pyproject.toml @@ -7,7 +7,7 @@ name = "orgmetra-people-api" version = "0.1.0" description = "Purpose-bound customer API boundary for authoritative Orgmetra HR records." readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" license = { text = "Apache-2.0" } authors = [{ name = "ContextualWisdomLab" }] dependencies = [ From d5c29371fba63952dc170f77c758e624e7b179d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:03:19 +0900 Subject: [PATCH 085/269] test(people): reject executable exact UUID payloads --- .../tests/test_uuid_payload_integrity.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 services/people-api/tests/test_uuid_payload_integrity.py diff --git a/services/people-api/tests/test_uuid_payload_integrity.py b/services/people-api/tests/test_uuid_payload_integrity.py new file mode 100644 index 000000000..c230fbf3d --- /dev/null +++ b/services/people-api/tests/test_uuid_payload_integrity.py @@ -0,0 +1,65 @@ +"""Reject forged exact UUID payloads before sentinel comparison in People boundaries.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_people_api.mutations import _validate_operational_uuid +from orgmetra_people_api.postgres_hire import _is_operational_uuid as _is_hire_operational_uuid +from orgmetra_people_api.postgres_mutations import ( + _is_operational_uuid as _is_mutation_operational_uuid, +) + +_OPERATIONAL_UUID = UUID("0198a412-9000-7000-8000-0000000000aa") + + +class _ExecutableUUIDPayload: + """Tripwire that exposes sentinel comparison before integer-payload validation.""" + + def __init__(self) -> None: + self.calls = 0 + + def __eq__(self, other: object) -> bool: + self.calls += 1 + raise TypeError("UUID payload equality executed before exact integer validation") + + def __ne__(self, other: object) -> bool: + self.calls += 1 + raise TypeError("UUID payload inequality executed before exact integer validation") + + +def _forged_uuid(payload: object) -> UUID: + """Return an exact UUID whose internal integer slot was rewritten after construction.""" + value = UUID(str(_OPERATIONAL_UUID)) + object.__setattr__(value, "int", payload) + return value + + +class PeopleUuidPayloadIntegrityTests(unittest.TestCase): + """Keep application and durable People UUID gates inert on corrupted exact UUIDs.""" + + def test_application_uuid_gate_rejects_executable_internal_payload(self) -> None: + payload = _ExecutableUUIDPayload() + + with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): + _validate_operational_uuid("tenant_record_id", _forged_uuid(payload)) + + self.assertEqual(payload.calls, 0) + + def test_postgres_uuid_gates_reject_executable_internal_payload(self) -> None: + for validator in (_is_hire_operational_uuid, _is_mutation_operational_uuid): + with self.subTest(validator=validator.__module__): + payload = _ExecutableUUIDPayload() + + self.assertFalse(validator(_forged_uuid(payload))) + self.assertEqual(payload.calls, 0) + + def test_exact_operational_uuid_remains_accepted(self) -> None: + _validate_operational_uuid("tenant_record_id", _OPERATIONAL_UUID) + self.assertTrue(_is_hire_operational_uuid(_OPERATIONAL_UUID)) + self.assertTrue(_is_mutation_operational_uuid(_OPERATIONAL_UUID)) + + +if __name__ == "__main__": + unittest.main() From 9b40b8ec904741ee2fd513b3168d52b00467c1e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:06:52 +0900 Subject: [PATCH 086/269] fix(people): validate UUID integer payload before comparison --- services/people-api/src/orgmetra_people_api/mutations.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 776c80955..ad4d1aa9e 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -47,8 +47,11 @@ class PeopleMutationIntegrityError(RuntimeError): def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require an exact UUID outside Orgmetra's reserved protocol sentinels.""" - if type(value) is not UUID or value.int in (0, _MAX_UUID_INT): + """Require an exact UUID with an inert integer payload outside reserved sentinels.""" + if type(value) is not UUID: + raise ValueError(f"{field_name} must be an operational UUID.") + identity = value.int + if type(identity) is not int or identity in (0, _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") @@ -512,4 +515,4 @@ def parse_allocation_ratio(raw_value: object) -> Decimal: """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" if type(raw_value) is not str or re.fullmatch(r"^(0\.(?!0000)[0-9]{4}|1\.0000)$", raw_value) is None: raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) + return Decimal(raw_value) \ No newline at end of file From a0b36955cbb8323ebf43055536757dde1610eb11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:08:06 +0900 Subject: [PATCH 087/269] fix(people): gate durable hire UUID payload --- .../people-api/src/orgmetra_people_api/postgres_hire.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 9ec7ff291..70b2203f7 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -165,8 +165,11 @@ def _is_operational_uuid(value: object) -> bool: - """Return whether a value is an Orgmetra operational UUID.""" - return type(value) is UUID and value.int not in (0, _MAX_UUID_INT) + """Return whether durable UUID evidence has an inert operational integer payload.""" + if type(value) is not UUID: + return False + identity = value.int + return type(identity) is int and identity not in (0, _MAX_UUID_INT) def _is_aware_datetime(value: object) -> bool: @@ -486,4 +489,4 @@ def accept_hire( person_record_id=command.person_record_id, employment_record_id=command.employment_record_id, candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, - ) + ) \ No newline at end of file From b863478f0b11a50b71873753db02682858c390fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:10:23 +0900 Subject: [PATCH 088/269] fix(people): gate durable mutation UUID payload --- .../src/orgmetra_people_api/postgres_mutations.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index c2a5cef3f..78710151b 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -253,8 +253,11 @@ def _is_operational_uuid(value: object) -> bool: - """Return whether a value is an exact operational UUID.""" - return type(value) is UUID and value.int not in (0, _MAX_UUID_INT) + """Return whether durable UUID evidence has an inert operational integer payload.""" + if type(value) is not UUID: + return False + identity = value.int + return type(identity) is int and identity not in (0, _MAX_UUID_INT) def _is_aware_datetime(value: object) -> bool: @@ -910,4 +913,4 @@ def create_assignment( authorization=decision, created_record_id=command.assignment_record_id, ) - return AssignmentMutationResult(assignment_record_id=command.assignment_record_id) + return AssignmentMutationResult(assignment_record_id=command.assignment_record_id) \ No newline at end of file From c72519edb5724ff5a6e1ef7ce0732e69ae847550 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:12:56 +0900 Subject: [PATCH 089/269] test(people): reject forged out-of-range UUID integers --- .../tests/test_uuid_payload_integrity.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_uuid_payload_integrity.py b/services/people-api/tests/test_uuid_payload_integrity.py index c230fbf3d..14db975a9 100644 --- a/services/people-api/tests/test_uuid_payload_integrity.py +++ b/services/people-api/tests/test_uuid_payload_integrity.py @@ -1,4 +1,4 @@ -"""Reject forged exact UUID payloads before sentinel comparison in People boundaries.""" +"""Reject forged exact UUID payloads before scalar use in People boundaries.""" from __future__ import annotations @@ -12,10 +12,11 @@ ) _OPERATIONAL_UUID = UUID("0198a412-9000-7000-8000-0000000000aa") +_OUT_OF_RANGE_IDENTITIES = (-1, 1 << 128) class _ExecutableUUIDPayload: - """Tripwire that exposes sentinel comparison before integer-payload validation.""" + """Tripwire that exposes scalar comparison before integer-payload validation.""" def __init__(self) -> None: self.calls = 0 @@ -55,6 +56,18 @@ def test_postgres_uuid_gates_reject_executable_internal_payload(self) -> None: self.assertFalse(validator(_forged_uuid(payload))) self.assertEqual(payload.calls, 0) + def test_application_uuid_gate_rejects_out_of_range_exact_integer_payload(self) -> None: + for identity in _OUT_OF_RANGE_IDENTITIES: + with self.subTest(identity=identity): + with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): + _validate_operational_uuid("tenant_record_id", _forged_uuid(identity)) + + def test_postgres_uuid_gates_reject_out_of_range_exact_integer_payload(self) -> None: + for validator in (_is_hire_operational_uuid, _is_mutation_operational_uuid): + for identity in _OUT_OF_RANGE_IDENTITIES: + with self.subTest(validator=validator.__module__, identity=identity): + self.assertFalse(validator(_forged_uuid(identity))) + def test_exact_operational_uuid_remains_accepted(self) -> None: _validate_operational_uuid("tenant_record_id", _OPERATIONAL_UUID) self.assertTrue(_is_hire_operational_uuid(_OPERATIONAL_UUID)) From 87a322434b9def6458b8d306c654809bf84588ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:15:05 +0900 Subject: [PATCH 090/269] fix(people): enforce UUID integer range --- services/people-api/src/orgmetra_people_api/mutations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index ad4d1aa9e..003edf151 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -47,11 +47,11 @@ class PeopleMutationIntegrityError(RuntimeError): def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require an exact UUID with an inert integer payload outside reserved sentinels.""" + """Require an exact UUID with an inert integer payload in the operational range.""" if type(value) is not UUID: raise ValueError(f"{field_name} must be an operational UUID.") identity = value.int - if type(identity) is not int or identity in (0, _MAX_UUID_INT): + if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") From 14066574c4715c3c2070037c7d1f617b1e9086ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:16:05 +0900 Subject: [PATCH 091/269] fix(people): enforce hire UUID integer range --- services/people-api/src/orgmetra_people_api/postgres_hire.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 70b2203f7..76066156a 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -169,7 +169,7 @@ def _is_operational_uuid(value: object) -> bool: if type(value) is not UUID: return False identity = value.int - return type(identity) is int and identity not in (0, _MAX_UUID_INT) + return type(identity) is int and 0 < identity < _MAX_UUID_INT def _is_aware_datetime(value: object) -> bool: From 598b64a883957f3d21cce97f68d1d8ef1b082c7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:17:27 +0900 Subject: [PATCH 092/269] fix(people): enforce mutation UUID integer range --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 78710151b..5810ea39f 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -257,7 +257,7 @@ def _is_operational_uuid(value: object) -> bool: if type(value) is not UUID: return False identity = value.int - return type(identity) is int and identity not in (0, _MAX_UUID_INT) + return type(identity) is int and 0 < identity < _MAX_UUID_INT def _is_aware_datetime(value: object) -> bool: From f4dac8a87350434621f7ab195a78aad8d8014465 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:18:35 +0900 Subject: [PATCH 093/269] test(people): cover all People UUID boundaries --- .../tests/test_uuid_payload_integrity.py | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/services/people-api/tests/test_uuid_payload_integrity.py b/services/people-api/tests/test_uuid_payload_integrity.py index 14db975a9..068f03a32 100644 --- a/services/people-api/tests/test_uuid_payload_integrity.py +++ b/services/people-api/tests/test_uuid_payload_integrity.py @@ -5,7 +5,9 @@ import unittest from uuid import UUID -from orgmetra_people_api.mutations import _validate_operational_uuid +from orgmetra_people_api.hire import _validate_operational_uuid as _validate_hire_operational_uuid +from orgmetra_people_api.mutations import _validate_operational_uuid as _validate_mutation_operational_uuid +from orgmetra_people_api.people import _validate_operational_uuid as _validate_read_operational_uuid from orgmetra_people_api.postgres_hire import _is_operational_uuid as _is_hire_operational_uuid from orgmetra_people_api.postgres_mutations import ( _is_operational_uuid as _is_mutation_operational_uuid, @@ -38,15 +40,21 @@ def _forged_uuid(payload: object) -> UUID: class PeopleUuidPayloadIntegrityTests(unittest.TestCase): - """Keep application and durable People UUID gates inert on corrupted exact UUIDs.""" - - def test_application_uuid_gate_rejects_executable_internal_payload(self) -> None: - payload = _ExecutableUUIDPayload() + """Keep all People UUID gates inert on corrupted exact UUIDs.""" + + def test_application_uuid_gates_reject_executable_internal_payload(self) -> None: + for validator in ( + _validate_hire_operational_uuid, + _validate_mutation_operational_uuid, + _validate_read_operational_uuid, + ): + with self.subTest(validator=validator.__module__): + payload = _ExecutableUUIDPayload() - with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): - _validate_operational_uuid("tenant_record_id", _forged_uuid(payload)) + with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): + validator("tenant_record_id", _forged_uuid(payload)) - self.assertEqual(payload.calls, 0) + self.assertEqual(payload.calls, 0) def test_postgres_uuid_gates_reject_executable_internal_payload(self) -> None: for validator in (_is_hire_operational_uuid, _is_mutation_operational_uuid): @@ -56,11 +64,16 @@ def test_postgres_uuid_gates_reject_executable_internal_payload(self) -> None: self.assertFalse(validator(_forged_uuid(payload))) self.assertEqual(payload.calls, 0) - def test_application_uuid_gate_rejects_out_of_range_exact_integer_payload(self) -> None: - for identity in _OUT_OF_RANGE_IDENTITIES: - with self.subTest(identity=identity): - with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): - _validate_operational_uuid("tenant_record_id", _forged_uuid(identity)) + def test_application_uuid_gates_reject_out_of_range_exact_integer_payload(self) -> None: + for validator in ( + _validate_hire_operational_uuid, + _validate_mutation_operational_uuid, + _validate_read_operational_uuid, + ): + for identity in _OUT_OF_RANGE_IDENTITIES: + with self.subTest(validator=validator.__module__, identity=identity): + with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): + validator("tenant_record_id", _forged_uuid(identity)) def test_postgres_uuid_gates_reject_out_of_range_exact_integer_payload(self) -> None: for validator in (_is_hire_operational_uuid, _is_mutation_operational_uuid): @@ -69,7 +82,13 @@ def test_postgres_uuid_gates_reject_out_of_range_exact_integer_payload(self) -> self.assertFalse(validator(_forged_uuid(identity))) def test_exact_operational_uuid_remains_accepted(self) -> None: - _validate_operational_uuid("tenant_record_id", _OPERATIONAL_UUID) + for validator in ( + _validate_hire_operational_uuid, + _validate_mutation_operational_uuid, + _validate_read_operational_uuid, + ): + with self.subTest(validator=validator.__module__): + validator("tenant_record_id", _OPERATIONAL_UUID) self.assertTrue(_is_hire_operational_uuid(_OPERATIONAL_UUID)) self.assertTrue(_is_mutation_operational_uuid(_OPERATIONAL_UUID)) From 3f24bfc205563e156a2f9508cdd1ea5762421014 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:19:07 +0900 Subject: [PATCH 094/269] fix(people): harden hire UUID payload validation --- services/people-api/src/orgmetra_people_api/hire.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 86ff5a625..c72a1d5b7 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -35,8 +35,11 @@ class HireDecisionIntegrityError(RuntimeError): def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require an exact UUID outside Orgmetra's reserved protocol sentinels.""" - if type(value) is not UUID or value.int in (0, _MAX_UUID_INT): + """Require an exact UUID with an inert integer payload in the operational range.""" + if type(value) is not UUID: + raise ValueError(f"{field_name} must be an operational UUID.") + identity = value.int + if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") @@ -179,4 +182,4 @@ def accept_confirmed_hire( or result.candidate_worker_conversion_record_id != expected_conversion_record_id ): raise HireDecisionIntegrityError("hire result identity does not match command") - return result + return result \ No newline at end of file From dab8487e39b067b276840a27f4315cfafd18324b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:19:37 +0900 Subject: [PATCH 095/269] fix(people): harden read UUID payload validation --- services/people-api/src/orgmetra_people_api/people.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/people.py b/services/people-api/src/orgmetra_people_api/people.py index 995559d30..79216ba1c 100644 --- a/services/people-api/src/orgmetra_people_api/people.py +++ b/services/people-api/src/orgmetra_people_api/people.py @@ -32,8 +32,11 @@ class PeopleRecordIntegrityError(RuntimeError): def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require a UUID that is not one of Orgmetra's reserved protocol sentinels.""" - if not isinstance(value, UUID) or value.int in (0, _MAX_UUID_INT): + """Require an exact UUID with an inert integer payload in the operational range.""" + if type(value) is not UUID: + raise ValueError(f"{field_name} must be an operational UUID.") + identity = value.int + if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") @@ -163,4 +166,4 @@ def read_worker_people_record( (field_name, _authorized_field_value(record, field_name)) for field_name in sorted(decision.authorized_fields) ), - ) + ) \ No newline at end of file From 0bc1c5348a640ef2c1327144b38657347951c533 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:20:38 +0900 Subject: [PATCH 096/269] chore(people): preserve dedicated read-owner boundary --- services/people-api/src/orgmetra_people_api/people.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/people.py b/services/people-api/src/orgmetra_people_api/people.py index 79216ba1c..51fcbdf42 100644 --- a/services/people-api/src/orgmetra_people_api/people.py +++ b/services/people-api/src/orgmetra_people_api/people.py @@ -32,11 +32,8 @@ class PeopleRecordIntegrityError(RuntimeError): def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require an exact UUID with an inert integer payload in the operational range.""" - if type(value) is not UUID: - raise ValueError(f"{field_name} must be an operational UUID.") - identity = value.int - if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): + """Require a UUID that is not one of Orgmetra's reserved protocol sentinels.""" + if not isinstance(value, UUID) or value.int in (0, _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") From e1f9da8214af43b40c7ea5480f91487c6347caa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:20:54 +0900 Subject: [PATCH 097/269] test(people): keep UUID regression in mutation owner --- .../tests/test_uuid_payload_integrity.py | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/services/people-api/tests/test_uuid_payload_integrity.py b/services/people-api/tests/test_uuid_payload_integrity.py index 068f03a32..0e2fe94bb 100644 --- a/services/people-api/tests/test_uuid_payload_integrity.py +++ b/services/people-api/tests/test_uuid_payload_integrity.py @@ -1,4 +1,4 @@ -"""Reject forged exact UUID payloads before scalar use in People boundaries.""" +"""Reject forged exact UUID payloads before scalar use in People mutation boundaries.""" from __future__ import annotations @@ -7,7 +7,6 @@ from orgmetra_people_api.hire import _validate_operational_uuid as _validate_hire_operational_uuid from orgmetra_people_api.mutations import _validate_operational_uuid as _validate_mutation_operational_uuid -from orgmetra_people_api.people import _validate_operational_uuid as _validate_read_operational_uuid from orgmetra_people_api.postgres_hire import _is_operational_uuid as _is_hire_operational_uuid from orgmetra_people_api.postgres_mutations import ( _is_operational_uuid as _is_mutation_operational_uuid, @@ -40,14 +39,10 @@ def _forged_uuid(payload: object) -> UUID: class PeopleUuidPayloadIntegrityTests(unittest.TestCase): - """Keep all People UUID gates inert on corrupted exact UUIDs.""" + """Keep mutation/application and durable UUID gates inert on corrupted exact UUIDs.""" def test_application_uuid_gates_reject_executable_internal_payload(self) -> None: - for validator in ( - _validate_hire_operational_uuid, - _validate_mutation_operational_uuid, - _validate_read_operational_uuid, - ): + for validator in (_validate_hire_operational_uuid, _validate_mutation_operational_uuid): with self.subTest(validator=validator.__module__): payload = _ExecutableUUIDPayload() @@ -65,11 +60,7 @@ def test_postgres_uuid_gates_reject_executable_internal_payload(self) -> None: self.assertEqual(payload.calls, 0) def test_application_uuid_gates_reject_out_of_range_exact_integer_payload(self) -> None: - for validator in ( - _validate_hire_operational_uuid, - _validate_mutation_operational_uuid, - _validate_read_operational_uuid, - ): + for validator in (_validate_hire_operational_uuid, _validate_mutation_operational_uuid): for identity in _OUT_OF_RANGE_IDENTITIES: with self.subTest(validator=validator.__module__, identity=identity): with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): @@ -82,11 +73,7 @@ def test_postgres_uuid_gates_reject_out_of_range_exact_integer_payload(self) -> self.assertFalse(validator(_forged_uuid(identity))) def test_exact_operational_uuid_remains_accepted(self) -> None: - for validator in ( - _validate_hire_operational_uuid, - _validate_mutation_operational_uuid, - _validate_read_operational_uuid, - ): + for validator in (_validate_hire_operational_uuid, _validate_mutation_operational_uuid): with self.subTest(validator=validator.__module__): validator("tenant_record_id", _OPERATIONAL_UUID) self.assertTrue(_is_hire_operational_uuid(_OPERATIONAL_UUID)) From b5ca7e4f021fc7af38c90154b47faa8891b99742 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 10:22:18 +0900 Subject: [PATCH 098/269] chore(people): restore read-owner bytes exactly --- services/people-api/src/orgmetra_people_api/people.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/people.py b/services/people-api/src/orgmetra_people_api/people.py index 51fcbdf42..995559d30 100644 --- a/services/people-api/src/orgmetra_people_api/people.py +++ b/services/people-api/src/orgmetra_people_api/people.py @@ -163,4 +163,4 @@ def read_worker_people_record( (field_name, _authorized_field_value(record, field_name)) for field_name in sorted(decision.authorized_fields) ), - ) \ No newline at end of file + ) From 9227ff2014df9d49568e37453f4e1b5b96b4dd47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:03:01 +0900 Subject: [PATCH 099/269] test(people): prove PostgreSQL capability replacement RED --- ..._postgres_connection_capability_binding.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 services/people-api/tests/test_postgres_connection_capability_binding.py diff --git a/services/people-api/tests/test_postgres_connection_capability_binding.py b/services/people-api/tests/test_postgres_connection_capability_binding.py new file mode 100644 index 000000000..35baf1d26 --- /dev/null +++ b/services/people-api/tests/test_postgres_connection_capability_binding.py @@ -0,0 +1,47 @@ +"""Regression contracts for PostgreSQL connection capability binding.""" + +from __future__ import annotations + +from contextlib import nullcontext +import unittest + +from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort + + +class PostgresConnectionCapabilityBindingTests(unittest.TestCase): + """Prove accepted database capabilities cannot be replaced after validation.""" + + def _assert_factory_remains_bound(self, port_type: type[object]) -> None: + calls: list[str] = [] + + def accepted_factory(): + calls.append("accepted") + return nullcontext(object()) + + def replacement_factory(): + calls.append("replacement") + return nullcontext(object()) + + port = port_type(accepted_factory) + try: + object.__setattr__(port, "connection_factory", replacement_factory) + except (AttributeError, TypeError): + pass + + self.assertIs(port.connection_factory, accepted_factory) + with port.connection_factory(): + pass + self.assertEqual(calls, ["accepted"]) + + def test_hire_port_keeps_the_exact_validated_connection_factory(self) -> None: + """Retained hire-port references cannot redirect later database execution.""" + self._assert_factory_remains_bound(PostgresHireAcceptancePort) + + def test_people_mutation_port_keeps_the_exact_validated_connection_factory(self) -> None: + """Retained mutation-port references cannot redirect later database execution.""" + self._assert_factory_remains_bound(PostgresPeopleMutationPort) + + +if __name__ == "__main__": + unittest.main() From b6bc808ffac4472f31198d8084e87be0f98a978b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:05:37 +0900 Subject: [PATCH 100/269] fix(people): bind validated hire connection capability --- .../src/orgmetra_people_api/postgres_hire.py | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 76066156a..478a7ead7 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -11,7 +11,6 @@ from __future__ import annotations from contextlib import AbstractContextManager -from dataclasses import dataclass from datetime import datetime, timezone from hashlib import sha256 import json @@ -295,25 +294,35 @@ def _record_hire_idempotency( ) -@dataclass(frozen=True, slots=True) -class PostgresHireAcceptancePort: +class PostgresHireAcceptancePort(tuple): """Persist confirmed hire facts and governance evidence in one DB transaction. ``connection_factory`` must return a DB-API connection context manager whose successful exit commits and exceptional exit rolls back, as psycopg - connections do. Fixed projections must cross this adapter boundary as exact - built-in list/tuple row collections containing exact built-in list/tuple - rows; custom row factories must normalize before durable evidence is read. - Pooling, TLS, credentials, and database roles remain a deployment concern - outside this service package. + connections do. The accepted executable factory is stored in the immutable + tuple payload so retained references cannot replace the validated database + capability before a later authoritative write. Fixed projections must cross + this adapter boundary as exact built-in list/tuple row collections containing + exact built-in list/tuple rows; custom row factories must normalize before + durable evidence is read. Pooling, TLS, credentials, and database roles + remain a deployment concern outside this service package. """ - connection_factory: PostgresConnectionFactory + __slots__ = () - def __post_init__(self) -> None: - """Reject unusable database factories before protected mutation is attempted.""" - if not callable(self.connection_factory): + def __new__( + cls, + connection_factory: PostgresConnectionFactory, + ) -> PostgresHireAcceptancePort: + """Validate and structurally bind the executable database capability.""" + if not callable(connection_factory): raise TypeError("connection_factory must be callable") + return tuple.__new__(cls, (connection_factory,)) + + @property + def connection_factory(self) -> PostgresConnectionFactory: + """Expose the exact factory retained by the structural binding.""" + return tuple.__getitem__(self, 0) def accept_hire( self, @@ -335,8 +344,9 @@ def accept_hire( raise TypeError("command must be a HireAcceptanceCommand") HireAcceptanceCommand.__post_init__(command) decision = _validate_authorization(command, authorization) + connection_factory = tuple.__getitem__(self, 0) - with self.connection_factory() as connection: + with connection_factory() as connection: with connection.cursor() as cursor: cursor.execute(_READ_WRITE_SQL) cursor.execute(_TENANT_CONTEXT_SQL, (str(command.tenant_record_id),)) @@ -489,4 +499,4 @@ def accept_hire( person_record_id=command.person_record_id, employment_record_id=command.employment_record_id, candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, - ) \ No newline at end of file + ) From 51f132b851eaa8fc881170eadfbe8bea6c02d012 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:07:46 +0900 Subject: [PATCH 101/269] fix(people): bind validated mutation connection capability --- .../orgmetra_people_api/postgres_mutations.py | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 5810ea39f..9cd6c30bf 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -9,7 +9,7 @@ from __future__ import annotations from contextlib import AbstractContextManager -from dataclasses import dataclass, replace +from dataclasses import replace from datetime import date, datetime, timezone from decimal import Decimal from typing import Any, Callable @@ -548,22 +548,33 @@ def _post_lock_recorded_at(cursor: Any) -> datetime: return recorded_at -@dataclass(frozen=True, slots=True) -class PostgresPeopleMutationPort: +class PostgresPeopleMutationPort(tuple): """Persist People mutations and governance evidence in one DB transaction. ``connection_factory`` must return a DB-API connection context manager whose - successful exit commits and exceptional exit rolls back. Fixed query - projections must arrive as exact built-in list/tuple batches and rows; - custom row factories must normalize before this trust boundary. + successful exit commits and exceptional exit rolls back. The accepted + executable factory is stored in the immutable tuple payload so retained + references cannot replace the validated database capability before later + authoritative writes. Fixed query projections must arrive as exact built-in + list/tuple batches and rows; custom row factories must normalize before this + trust boundary. """ - connection_factory: PostgresConnectionFactory + __slots__ = () - def __post_init__(self) -> None: - """Reject unusable database factories before protected mutation is attempted.""" - if not callable(self.connection_factory): + def __new__( + cls, + connection_factory: PostgresConnectionFactory, + ) -> PostgresPeopleMutationPort: + """Validate and structurally bind the executable database capability.""" + if not callable(connection_factory): raise TypeError("connection_factory must be callable") + return tuple.__new__(cls, (connection_factory,)) + + @property + def connection_factory(self) -> PostgresConnectionFactory: + """Expose the exact factory retained by the structural binding.""" + return tuple.__getitem__(self, 0) def create_employment( self, @@ -582,7 +593,8 @@ def create_employment( resource_kind="employment_record", requested_fields=_EMPLOYMENT_FIELDS, ) - with self.connection_factory() as connection: + connection_factory = tuple.__getitem__(self, 0) + with connection_factory() as connection: with connection.cursor() as cursor: cursor.execute(_READ_WRITE_SQL) cursor.execute(_TENANT_CONTEXT_SQL, (str(command.tenant_record_id),)) @@ -695,7 +707,8 @@ def create_position( resource_kind="position_record", requested_fields=_POSITION_FIELDS, ) - with self.connection_factory() as connection: + connection_factory = tuple.__getitem__(self, 0) + with connection_factory() as connection: with connection.cursor() as cursor: cursor.execute(_READ_WRITE_SQL) cursor.execute(_TENANT_CONTEXT_SQL, (str(command.tenant_record_id),)) @@ -796,7 +809,8 @@ def create_assignment( resource_kind="assignment_record", requested_fields=_ASSIGNMENT_FIELDS, ) - with self.connection_factory() as connection: + connection_factory = tuple.__getitem__(self, 0) + with connection_factory() as connection: with connection.cursor() as cursor: cursor.execute(_READ_WRITE_SQL) cursor.execute(_TENANT_CONTEXT_SQL, (str(command.tenant_record_id),)) @@ -913,4 +927,4 @@ def create_assignment( authorization=decision, created_record_id=command.assignment_record_id, ) - return AssignmentMutationResult(assignment_record_id=command.assignment_record_id) \ No newline at end of file + return AssignmentMutationResult(assignment_record_id=command.assignment_record_id) From 2a2dd54dd92c134535aeb0c34e6664a770a7ee6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:09:36 +0900 Subject: [PATCH 102/269] test(people): forbid descriptor capability indirection --- .../test_postgres_connection_capability_binding.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/services/people-api/tests/test_postgres_connection_capability_binding.py b/services/people-api/tests/test_postgres_connection_capability_binding.py index 35baf1d26..afbcbb90c 100644 --- a/services/people-api/tests/test_postgres_connection_capability_binding.py +++ b/services/people-api/tests/test_postgres_connection_capability_binding.py @@ -24,13 +24,13 @@ def replacement_factory(): return nullcontext(object()) port = port_type(accepted_factory) - try: + with self.assertRaises((AttributeError, TypeError)): object.__setattr__(port, "connection_factory", replacement_factory) - except (AttributeError, TypeError): - pass - self.assertIs(port.connection_factory, accepted_factory) - with port.connection_factory(): + self.assertFalse(hasattr(type(port), "connection_factory")) + bound_factory = tuple.__getitem__(port, 0) + self.assertIs(bound_factory, accepted_factory) + with bound_factory(): pass self.assertEqual(calls, ["accepted"]) From 368951e7778e71c7ef9ca452ef1e50901a8829de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:10:26 +0900 Subject: [PATCH 103/269] fix(people): remove descriptor capability path --- services/people-api/src/orgmetra_people_api/postgres_hire.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 478a7ead7..8abd6b0ca 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -319,11 +319,6 @@ def __new__( raise TypeError("connection_factory must be callable") return tuple.__new__(cls, (connection_factory,)) - @property - def connection_factory(self) -> PostgresConnectionFactory: - """Expose the exact factory retained by the structural binding.""" - return tuple.__getitem__(self, 0) - def accept_hire( self, *, From f566fd2c9dc95878bd7978baaf4b5bcd5d78de3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:12:11 +0900 Subject: [PATCH 104/269] fix(people): remove descriptor capability path --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 9cd6c30bf..b90ebcf83 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -571,11 +571,6 @@ def __new__( raise TypeError("connection_factory must be callable") return tuple.__new__(cls, (connection_factory,)) - @property - def connection_factory(self) -> PostgresConnectionFactory: - """Expose the exact factory retained by the structural binding.""" - return tuple.__getitem__(self, 0) - def create_employment( self, *, From 338305f4e160724ad449edbbf7ca2e5acf571f7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:32:10 +0900 Subject: [PATCH 105/269] test(people): expose direct hire command drift during DB acquisition --- .../test_hire_post_construction_integrity.py | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_hire_post_construction_integrity.py b/services/people-api/tests/test_hire_post_construction_integrity.py index 3ba432939..53b5a0620 100644 --- a/services/people-api/tests/test_hire_post_construction_integrity.py +++ b/services/people-api/tests/test_hire_post_construction_integrity.py @@ -7,7 +7,7 @@ import pytest -from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy from orgmetra_people_api.auth import AuthenticatedPrincipal from orgmetra_people_api.hire import ( HireAcceptanceCommand, @@ -17,6 +17,7 @@ from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort TENANT = UUID("0198a412-7800-7000-8000-000000000001") +REWRITTEN_TENANT = UUID("0198a412-7800-7000-8000-0000000000fe") SELECTION_DECISION = UUID("0198a412-7800-7000-8000-000000000002") PERSON = UUID("0198a412-7800-7000-8000-000000000003") EMPLOYMENT = UUID("0198a412-7800-7000-8000-000000000004") @@ -66,6 +67,53 @@ def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) return result +class _StopAfterTenantContext(RuntimeError): + """Stop the durable-port regression after the tenant context is observed.""" + + +class _TenantCaptureCursor: + """Capture the first parameterized SQL call after database acquisition.""" + + def __init__(self) -> None: + """Start without an observed tenant context.""" + self.parameters: tuple[object, ...] | None = None + + def __enter__(self) -> _TenantCaptureCursor: + """Enter the deterministic cursor context.""" + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Leave exception propagation unchanged.""" + return None + + def execute(self, sql: str, parameters: tuple[object, ...] | None = None) -> None: + """Stop once the tenant-setting call exposes which command snapshot was used.""" + del sql + if parameters is not None: + self.parameters = parameters + raise _StopAfterTenantContext + + +class _TenantCaptureConnection: + """Expose the tenant-capture cursor through the expected DB-API shape.""" + + def __init__(self, cursor: _TenantCaptureCursor) -> None: + """Bind the deterministic cursor.""" + self._cursor = cursor + + def __enter__(self) -> _TenantCaptureConnection: + """Enter the deterministic connection context.""" + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + """Leave exception propagation unchanged.""" + return None + + def cursor(self) -> _TenantCaptureCursor: + """Return the deterministic cursor.""" + return self._cursor + + def _command() -> HireAcceptanceCommand: """Build one valid confirmed-hire command before deliberate low-level rewrite.""" return HireAcceptanceCommand( @@ -108,6 +156,24 @@ def _policy() -> PurposeBoundAccessPolicy: ) +def _authorization() -> AuthorizationDecision: + """Return the exact durable-port allow decision for the original command snapshot.""" + return AuthorizationDecision( + allowed=True, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-228", + resource_reference=f"selection_decision:{SELECTION_DECISION.hex}", + policy_version_code="people-hire-v1", + purpose_code="candidate_hire", + operation_code="materialize_worker", + resource_kind="selection_decision", + requested_fields=frozenset({"candidate_worker_conversion"}), + authorized_fields=frozenset({"candidate_worker_conversion"}), + reason_code="access_permitted", + next_action="continue", + ) + + def _rewrite_selection_decision(command: HireAcceptanceCommand) -> None: """Replace one validated UUID with executable subtype evidence after construction.""" object.__setattr__( @@ -160,3 +226,21 @@ def test_postgres_port_revalidates_rewritten_hire_command_before_authorization_o with pytest.raises(ValueError, match="selection_decision_id must be an operational UUID"): port.accept_hire(command=command, authorization=object()) # type: ignore[arg-type] + + +def test_postgres_port_detaches_hire_command_before_database_acquisition() -> None: + """A connection-factory side effect must not change the already-authorized durable command.""" + command = _command() + cursor = _TenantCaptureCursor() + + def mutating_connection_factory() -> _TenantCaptureConnection: + object.__setattr__(command, "tenant_record_id", REWRITTEN_TENANT) + return _TenantCaptureConnection(cursor) + + port = PostgresHireAcceptancePort(connection_factory=mutating_connection_factory) + + with pytest.raises(_StopAfterTenantContext): + port.accept_hire(command=command, authorization=_authorization()) + + assert command.tenant_record_id == REWRITTEN_TENANT + assert cursor.parameters == (str(TENANT),) From e01e82594582685477c79bdd7360d1c7c01b9c0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 11:33:32 +0900 Subject: [PATCH 106/269] fix(people): snapshot hire command before database acquisition --- services/people-api/src/orgmetra_people_api/postgres_hire.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_hire.py b/services/people-api/src/orgmetra_people_api/postgres_hire.py index 8abd6b0ca..3cd74245c 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -11,6 +11,7 @@ from __future__ import annotations from contextlib import AbstractContextManager +from dataclasses import replace from datetime import datetime, timezone from hashlib import sha256 import json @@ -337,7 +338,7 @@ def accept_hire( """ if type(command) is not HireAcceptanceCommand: raise TypeError("command must be a HireAcceptanceCommand") - HireAcceptanceCommand.__post_init__(command) + command = replace(command) decision = _validate_authorization(command, authorization) connection_factory = tuple.__getitem__(self, 0) From 86a346b6aa13af67bf6f7c59723d9a4bb79003e4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:06:09 +0900 Subject: [PATCH 107/269] test(people): expose retained hire UUID alias during authorization --- .../tests/test_hire_authorization_command_snapshot.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/services/people-api/tests/test_hire_authorization_command_snapshot.py b/services/people-api/tests/test_hire_authorization_command_snapshot.py index 30804e05f..39b007af6 100644 --- a/services/people-api/tests/test_hire_authorization_command_snapshot.py +++ b/services/people-api/tests/test_hire_authorization_command_snapshot.py @@ -16,6 +16,7 @@ TENANT = UUID("0198a412-c200-7000-8000-000000000001") CANDIDATE = UUID("0198a412-c200-7000-8000-000000000010") +MUTATED_CANDIDATE = UUID("0198a412-c200-7000-8000-000000000012") SELECTION_DECISION = UUID("0198a412-c200-7000-8000-000000000011") PERSON = UUID("0198a412-c200-7000-8000-000000000020") PERSON_NAME = UUID("0198a412-c200-7000-8000-000000000021") @@ -50,8 +51,9 @@ def __new__( return instance def _mutate_command(self) -> None: - """Rewrite valid PII after application validation but during authorization.""" + """Rewrite valid PII and nested UUID state during authorization.""" object.__setattr__(self.command, "display_name", MUTATED_DISPLAY_NAME) + object.__setattr__(self.command.candidate_profile_id, "int", MUTATED_CANDIDATE.int) def __eq__(self, other: object) -> bool: """Mutate before preserving ordinary string equality semantics.""" @@ -89,7 +91,7 @@ def _command() -> HireAcceptanceCommand: """Build one valid confirmed-hire command for authorization interleaving.""" return HireAcceptanceCommand( tenant_record_id=TENANT, - candidate_profile_id=CANDIDATE, + candidate_profile_id=UUID(int=CANDIDATE.int), selection_decision_id=SELECTION_DECISION, person_record_id=PERSON, person_name_record_id=PERSON_NAME, @@ -121,7 +123,7 @@ class HireAuthorizationCommandSnapshotTests(unittest.TestCase): """Require authorization callbacks to have no authority over the port hire command.""" def test_hire_port_receives_pre_authorization_semantics(self) -> None: - """Policy execution may mutate caller PII but not the detached port command.""" + """Policy execution may mutate caller state but not the detached port command.""" command = _command() port = _CapturingHirePort() @@ -134,9 +136,12 @@ def test_hire_port_receives_pre_authorization_semantics(self) -> None: ) self.assertEqual(command.display_name, MUTATED_DISPLAY_NAME) + self.assertEqual(command.candidate_profile_id, MUTATED_CANDIDATE) self.assertIsNotNone(port.command) assert port.command is not None self.assertEqual(port.command.display_name, ORIGINAL_DISPLAY_NAME) + self.assertEqual(port.command.candidate_profile_id, CANDIDATE) + self.assertIsNot(port.command.candidate_profile_id, command.candidate_profile_id) self.assertIsNot(port.command, command) self.assertEqual(result.person_record_id, PERSON) self.assertEqual(result.employment_record_id, EMPLOYMENT) From b01ffad4de6f78a771ad3dc14f486634917e1c03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:06:49 +0900 Subject: [PATCH 108/269] fix(people): detach nested hire UUID aliases at command validation --- services/people-api/src/orgmetra_people_api/hire.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index c72a1d5b7..7ee73f45b 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -34,13 +34,14 @@ class HireDecisionIntegrityError(RuntimeError): """Indicate that decision provenance cannot safely materialize worker truth.""" -def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require an exact UUID with an inert integer payload in the operational range.""" +def _validate_operational_uuid(field_name: str, value: object) -> int: + """Return the inert integer payload of one exact operational UUID.""" if type(value) is not UUID: raise ValueError(f"{field_name} must be an operational UUID.") identity = value.int if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") + return identity @dataclass(frozen=True, slots=True) @@ -70,7 +71,7 @@ class HireAcceptanceCommand: employment_status_code: str = "active" def __post_init__(self) -> None: - """Fail closed before authorization or persistence on malformed input.""" + """Fail closed and detach UUID aliases before authorization or persistence.""" for field_name in ( "tenant_record_id", "candidate_profile_id", @@ -83,7 +84,8 @@ def __post_init__(self) -> None: "audit_event_record_id", "outbox_delivery_record_id", ): - _validate_operational_uuid(field_name, getattr(self, field_name)) + identity = _validate_operational_uuid(field_name, getattr(self, field_name)) + object.__setattr__(self, field_name, UUID(int=identity)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") if type(self.display_name) is not str: @@ -182,4 +184,4 @@ def accept_confirmed_hire( or result.candidate_worker_conversion_record_id != expected_conversion_record_id ): raise HireDecisionIntegrityError("hire result identity does not match command") - return result \ No newline at end of file + return result From 728c63de4d49e8c82b2b29450003d235cc90a2ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:14:51 +0900 Subject: [PATCH 109/269] test(people): expose generic nested UUID alias across authorization --- ...mutation_authorization_command_snapshot.py | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py b/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py index d67c91b18..4ef969eec 100644 --- a/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py +++ b/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py @@ -54,16 +54,25 @@ def __new__( command: object, field_name: str, replacement: object, + mutate_nested_uuid: bool = False, ) -> _MutatingResourceKind: - """Retain the caller command solely for the adversarial comparison callback.""" + """Retain caller state solely for the adversarial comparison callback.""" instance = super().__new__(cls, value) instance.command = command instance.field_name = field_name instance.replacement = replacement + instance.mutate_nested_uuid = mutate_nested_uuid return instance def _mutate_command(self) -> None: """Simulate caller-owned executable policy behavior during authorization.""" + if self.mutate_nested_uuid: + nested_uuid = getattr(self.command, self.field_name) + replacement_uuid = self.replacement + if type(nested_uuid) is not UUID or type(replacement_uuid) is not UUID: + raise TypeError("nested UUID mutation requires exact UUID values") + object.__setattr__(nested_uuid, "int", replacement_uuid.int) + return object.__setattr__(self.command, self.field_name, self.replacement) def __eq__(self, other: object) -> bool: @@ -125,6 +134,7 @@ def _policy( command: object, command_field_name: str, replacement: object, + mutate_nested_uuid: bool = False, ) -> PurposeBoundAccessPolicy: """Build a valid policy whose resource-kind comparison mutates caller state.""" return PurposeBoundAccessPolicy( @@ -135,6 +145,7 @@ def _policy( command=command, field_name=command_field_name, replacement=replacement, + mutate_nested_uuid=mutate_nested_uuid, ), purpose_code="workforce_admin", operation_code="create_record", @@ -223,6 +234,31 @@ def test_employment_port_receives_pre_authorization_semantics(self) -> None: self.assertEqual(port.employment_command.person_record_id, PERSON) self.assertIsNot(port.employment_command, command) + def test_employment_port_detaches_nested_uuid_authority(self) -> None: + """Retained UUID mutation must not cross the Employment policy boundary.""" + command = _employment_command() + object.__setattr__(command, "person_record_id", UUID(int=PERSON.int)) + port = _CapturingMutationPort() + create_employment_record( + principal=PRINCIPAL, + command=command, + purpose_code="workforce_admin", + policy=_policy( + resource_kind="employment_record", + field_name="employment_record", + command=command, + command_field_name="person_record_id", + replacement=OTHER_PERSON, + mutate_nested_uuid=True, + ), + mutation_port=port, + ) + self.assertEqual(command.person_record_id, OTHER_PERSON) + self.assertIsNotNone(port.employment_command) + assert port.employment_command is not None + self.assertEqual(port.employment_command.person_record_id, PERSON) + self.assertIsNot(port.employment_command.person_record_id, command.person_record_id) + def test_position_port_receives_pre_authorization_semantics(self) -> None: """Policy execution may mutate caller state but not the Position port command.""" command = _position_command() @@ -246,6 +282,31 @@ def test_position_port_receives_pre_authorization_semantics(self) -> None: self.assertEqual(port.position_command.job_profile_id, JOB) self.assertIsNot(port.position_command, command) + def test_position_port_detaches_nested_uuid_authority(self) -> None: + """Retained UUID mutation must not cross the Position policy boundary.""" + command = _position_command() + object.__setattr__(command, "job_profile_id", UUID(int=JOB.int)) + port = _CapturingMutationPort() + create_position_record( + principal=PRINCIPAL, + command=command, + purpose_code="workforce_admin", + policy=_policy( + resource_kind="position_record", + field_name="position_record", + command=command, + command_field_name="job_profile_id", + replacement=OTHER_JOB, + mutate_nested_uuid=True, + ), + mutation_port=port, + ) + self.assertEqual(command.job_profile_id, OTHER_JOB) + self.assertIsNotNone(port.position_command) + assert port.position_command is not None + self.assertEqual(port.position_command.job_profile_id, JOB) + self.assertIsNot(port.position_command.job_profile_id, command.job_profile_id) + def test_assignment_port_receives_pre_authorization_semantics(self) -> None: """Policy execution may mutate caller state but not the Assignment port command.""" command = _assignment_command() @@ -269,6 +330,31 @@ def test_assignment_port_receives_pre_authorization_semantics(self) -> None: self.assertEqual(port.assignment_command.position_record_id, POSITION) self.assertIsNot(port.assignment_command, command) + def test_assignment_port_detaches_nested_uuid_authority(self) -> None: + """Retained UUID mutation must not cross the Assignment policy boundary.""" + command = _assignment_command() + object.__setattr__(command, "position_record_id", UUID(int=POSITION.int)) + port = _CapturingMutationPort() + create_assignment_record( + principal=PRINCIPAL, + command=command, + purpose_code="workforce_admin", + policy=_policy( + resource_kind="assignment_record", + field_name="assignment_record", + command=command, + command_field_name="position_record_id", + replacement=OTHER_POSITION, + mutate_nested_uuid=True, + ), + mutation_port=port, + ) + self.assertEqual(command.position_record_id, OTHER_POSITION) + self.assertIsNotNone(port.assignment_command) + assert port.assignment_command is not None + self.assertEqual(port.assignment_command.position_record_id, POSITION) + self.assertIsNot(port.assignment_command.position_record_id, command.position_record_id) + if __name__ == "__main__": unittest.main() From 5a7be538fe7e6496fff4b76024f0f5cfb983934f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:15:24 +0900 Subject: [PATCH 110/269] test(people): expose generic nested UUID alias at PostgreSQL boundary --- ...es_mutation_post_construction_integrity.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py index 4e6858494..ec98d34f1 100644 --- a/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py +++ b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py @@ -197,3 +197,49 @@ def mutating_connection_factory() -> FakeConnection: ) assert insert_position[1] is not None assert insert_position[1][2] == ORGANIZATION + + +def test_postgres_position_detaches_nested_uuid_before_connection_factory_callback() -> None: + """Keep nested Position identity authority fixed across connection acquisition.""" + command = PositionMutationCommand( + tenant_record_id=TENANT, + organization_unit_id=UUID(int=ORGANIZATION.int), + job_profile_id=JOB_PROFILE, + position_record_id=POSITION, + position_record_version_id=POSITION_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + position_status_code="open", + effective_from=date(2026, 9, 5), + confirmation_reference="human_confirmation:post-construction-268-position", + evidence_version_code="position-evidence-v1", + idempotency_key="post-construction-runtime-268-position", + ) + cursor = ScriptedCursor( + [[], [(ORGANIZATION, JOB_PROFILE, RECORDED_AT)]], + [], + ) + connection = FakeConnection(cursor) + + def mutating_connection_factory() -> FakeConnection: + """Rewrite the caller-owned nested UUID only after direct-port validation.""" + object.__setattr__(command.organization_unit_id, "int", MUTATED_ORGANIZATION.int) + return connection + + port = PostgresPeopleMutationPort(mutating_connection_factory) + result = port.create_position( + command=command, + authorization=_authorization(resource_kind="position_record", record_id=POSITION), + ) + + assert command.organization_unit_id == MUTATED_ORGANIZATION + assert result.position_record_id == POSITION + parent_query = next( + execution for execution in cursor.executions if "FROM public.organization_unit AS organization" in execution[0] + ) + assert parent_query[1] == (JOB_PROFILE, TENANT, ORGANIZATION) + insert_position = next( + execution for execution in cursor.executions if execution[0].startswith("INSERT INTO public.position_record (") + ) + assert insert_position[1] is not None + assert insert_position[1][2] == ORGANIZATION From 6c8be6d44e28fe77bfe52a2e0946df4e0cbe3b83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:16:20 +0900 Subject: [PATCH 111/269] fix(people): detach generic nested UUID aliases at validation --- .../src/orgmetra_people_api/mutations.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 003edf151..708aab5c7 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -46,13 +46,14 @@ class PeopleMutationIntegrityError(RuntimeError): """Indicate that the mutation cannot persist without violating employment truth.""" -def _validate_operational_uuid(field_name: str, value: object) -> None: - """Require an exact UUID with an inert integer payload in the operational range.""" +def _validate_operational_uuid(field_name: str, value: object) -> int: + """Return the inert integer payload of one exact operational UUID.""" if type(value) is not UUID: raise ValueError(f"{field_name} must be an operational UUID.") identity = value.int if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") + return identity def _validate_confirmation(value: object) -> None: @@ -186,7 +187,7 @@ class EmploymentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed before authorization or persistence on malformed input.""" + """Fail closed and detach UUID aliases before authorization or persistence.""" for field_name in ( "tenant_record_id", "person_record_id", @@ -195,7 +196,8 @@ def __post_init__(self) -> None: "audit_event_record_id", "outbox_delivery_record_id", ): - _validate_operational_uuid(field_name, getattr(self, field_name)) + identity = _validate_operational_uuid(field_name, getattr(self, field_name)) + object.__setattr__(self, field_name, UUID(int=identity)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") if type(self.employment_status_code) is not str or self.employment_status_code not in _EMPLOYMENT_STATUSES: @@ -228,7 +230,7 @@ class PositionMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed before authorization or persistence on malformed input.""" + """Fail closed and detach UUID aliases before authorization or persistence.""" for field_name in ( "tenant_record_id", "organization_unit_id", @@ -238,7 +240,8 @@ def __post_init__(self) -> None: "audit_event_record_id", "outbox_delivery_record_id", ): - _validate_operational_uuid(field_name, getattr(self, field_name)) + identity = _validate_operational_uuid(field_name, getattr(self, field_name)) + object.__setattr__(self, field_name, UUID(int=identity)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") if type(self.position_status_code) is not str or self.position_status_code not in _POSITION_STATUSES: @@ -266,7 +269,7 @@ class AssignmentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed before authorization or persistence on malformed input.""" + """Fail closed and detach UUID aliases before authorization or persistence.""" for field_name in ( "tenant_record_id", "employment_record_id", @@ -276,7 +279,8 @@ def __post_init__(self) -> None: "audit_event_record_id", "outbox_delivery_record_id", ): - _validate_operational_uuid(field_name, getattr(self, field_name)) + identity = _validate_operational_uuid(field_name, getattr(self, field_name)) + object.__setattr__(self, field_name, UUID(int=identity)) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") if type(self.allocation_ratio) is not Decimal: From 0cd270fc6d6f8273f7fb783f9425810493e7d7ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:31:06 +0900 Subject: [PATCH 112/269] test(people): expose retained result alias drift --- ...people_mutation_result_alias_detachment.py | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_result_alias_detachment.py diff --git a/services/people-api/tests/test_people_mutation_result_alias_detachment.py b/services/people-api/tests/test_people_mutation_result_alias_detachment.py new file mode 100644 index 000000000..9e21f2297 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_result_alias_detachment.py @@ -0,0 +1,300 @@ +"""Retained result-alias regressions for authoritative People mutation boundaries.""" + +from __future__ import annotations + +from datetime import date +from decimal import Decimal +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.hire import ( + HireAcceptanceCommand, + HireAcceptanceResult, + accept_confirmed_hire, +) +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + AssignmentMutationResult, + EmploymentMutationCommand, + EmploymentMutationResult, + PositionMutationCommand, + PositionMutationResult, + create_assignment_record, + create_employment_record, + create_position_record, +) + +TENANT = UUID("0198a412-b300-7000-8000-000000000001") +PERSON = UUID("0198a412-b300-7000-8000-000000000020") +PERSON_NAME = UUID("0198a412-b300-7000-8000-000000000021") +CANDIDATE = UUID("0198a412-b300-7000-8000-000000000022") +SELECTION_DECISION = UUID("0198a412-b300-7000-8000-000000000023") +EMPLOYMENT = UUID("0198a412-b300-7000-8000-000000000030") +EMPLOYMENT_VERSION = UUID("0198a412-b300-7000-8000-000000000031") +POSITION = UUID("0198a412-b300-7000-8000-000000000040") +POSITION_VERSION = UUID("0198a412-b300-7000-8000-000000000041") +ORGANIZATION = UUID("0198a412-b300-7000-8000-000000000050") +JOB = UUID("0198a412-b300-7000-8000-000000000060") +ASSIGNMENT = UUID("0198a412-b300-7000-8000-000000000070") +CONVERSION = UUID("0198a412-b300-7000-8000-000000000071") +AUDIT_EVENT = UUID("0198a412-b300-7000-8000-000000000080") +OUTBOX = UUID("0198a412-b300-7000-8000-000000000081") +OTHER = UUID("0198a412-b300-7000-8000-000000000099") +EFFECTIVE_FROM = date(2026, 9, 7) + +PRINCIPAL = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:result-alias-operator", + granted_scope_codes=frozenset( + { + "orgmetra.people.write", + "orgmetra.job_architecture.write", + "orgmetra.people.materialize_worker", + } + ), +) + + +def _policy( + *, + resource_kind: str, + purpose_code: str, + operation_code: str, + scope_code: str, + field_name: str, +) -> PurposeBoundAccessPolicy: + """Build one exact purpose-bound policy for a focused result boundary.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="result-alias-v1", + resource_kind=resource_kind, + purpose_code=purpose_code, + operation_code=operation_code, + required_scope_code=scope_code, + permitted_fields=frozenset({field_name}), + ) + + +def _employment_command() -> EmploymentMutationCommand: + """Build one valid Employment create command.""" + return EmploymentMutationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:result-alias", + evidence_version_code="result-alias-v1", + idempotency_key="result-alias-employment-1", + ) + + +def _position_command() -> PositionMutationCommand: + """Build one valid Position create command.""" + return PositionMutationCommand( + tenant_record_id=TENANT, + organization_unit_id=ORGANIZATION, + job_profile_id=JOB, + position_record_id=POSITION, + position_record_version_id=POSITION_VERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + position_status_code="open", + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:result-alias", + evidence_version_code="result-alias-v1", + idempotency_key="result-alias-position-1", + ) + + +def _assignment_command() -> AssignmentMutationCommand: + """Build one valid Assignment create command.""" + return AssignmentMutationCommand( + tenant_record_id=TENANT, + employment_record_id=EMPLOYMENT, + person_record_id=PERSON, + position_record_id=POSITION, + assignment_record_id=ASSIGNMENT, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + allocation_ratio=Decimal("1.0000"), + effective_from=EFFECTIVE_FROM, + confirmation_reference="human_confirmation:result-alias", + evidence_version_code="result-alias-v1", + idempotency_key="result-alias-assignment-1", + ) + + +def _hire_command() -> HireAcceptanceCommand: + """Build one valid confirmed-hire command.""" + return HireAcceptanceCommand( + tenant_record_id=TENANT, + candidate_profile_id=CANDIDATE, + selection_decision_id=SELECTION_DECISION, + person_record_id=PERSON, + person_name_record_id=PERSON_NAME, + employment_record_id=EMPLOYMENT, + employment_record_version_id=EMPLOYMENT_VERSION, + candidate_worker_conversion_record_id=CONVERSION, + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + effective_from=EFFECTIVE_FROM, + display_name="Result Alias Worker", + idempotency_key="result-alias-hire-1", + ) + + +class _RetainedPeopleResultPort: + """Return exact result objects while retaining the same mutable object aliases.""" + + def __init__( + self, + *, + employment_result: EmploymentMutationResult | None = None, + position_result: PositionMutationResult | None = None, + assignment_result: AssignmentMutationResult | None = None, + ) -> None: + """Retain each result so the adapter can mutate it after service return.""" + self.employment_result = employment_result + self.position_result = position_result + self.assignment_result = assignment_result + + def create_employment(self, *, command: EmploymentMutationCommand, authorization: object) -> EmploymentMutationResult: + """Return the retained Employment result.""" + del command, authorization + assert self.employment_result is not None + return self.employment_result + + def create_position(self, *, command: PositionMutationCommand, authorization: object) -> PositionMutationResult: + """Return the retained Position result.""" + del command, authorization + assert self.position_result is not None + return self.position_result + + def create_assignment(self, *, command: AssignmentMutationCommand, authorization: object) -> AssignmentMutationResult: + """Return the retained Assignment result.""" + del command, authorization + assert self.assignment_result is not None + return self.assignment_result + + +class _RetainedHireResultPort: + """Return one exact hire result while retaining the same mutable object alias.""" + + def __init__(self, result: HireAcceptanceResult) -> None: + """Retain the result so the adapter can mutate it after service return.""" + self.result = result + + def accept_hire(self, *, command: HireAcceptanceCommand, authorization: object) -> HireAcceptanceResult: + """Return the retained confirmed-hire result.""" + del command, authorization + return self.result + + +class PeopleMutationResultAliasDetachmentTests(unittest.TestCase): + """Require service outputs to stop sharing result objects or UUID payloads with ports.""" + + @staticmethod + def _mutate_retained_result(result: object, field_name: str) -> None: + """Rewrite both a retained nested UUID and then its containing result field.""" + retained_uuid = getattr(result, field_name) + object.__setattr__(retained_uuid, "int", OTHER.int) + object.__setattr__(result, field_name, OTHER) + + def test_employment_result_is_detached_from_port_alias(self) -> None: + """A port must not rewrite an accepted Employment result after service return.""" + source = EmploymentMutationResult(employment_record_id=EMPLOYMENT) + port = _RetainedPeopleResultPort(employment_result=source) + returned = create_employment_record( + principal=PRINCIPAL, + command=_employment_command(), + purpose_code="workforce_admin", + policy=_policy( + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="create_record", + scope_code="orgmetra.people.write", + field_name="employment_record", + ), + mutation_port=port, + ) + self._mutate_retained_result(source, "employment_record_id") + self.assertEqual(returned.employment_record_id, EMPLOYMENT) + + def test_position_result_is_detached_from_port_alias(self) -> None: + """A port must not rewrite an accepted Position result after service return.""" + source = PositionMutationResult(position_record_id=POSITION) + port = _RetainedPeopleResultPort(position_result=source) + returned = create_position_record( + principal=PRINCIPAL, + command=_position_command(), + purpose_code="job_architecture_admin", + policy=_policy( + resource_kind="position_record", + purpose_code="job_architecture_admin", + operation_code="create_record", + scope_code="orgmetra.job_architecture.write", + field_name="position_record", + ), + mutation_port=port, + ) + self._mutate_retained_result(source, "position_record_id") + self.assertEqual(returned.position_record_id, POSITION) + + def test_assignment_result_is_detached_from_port_alias(self) -> None: + """A port must not rewrite an accepted Assignment result after service return.""" + source = AssignmentMutationResult(assignment_record_id=ASSIGNMENT) + port = _RetainedPeopleResultPort(assignment_result=source) + returned = create_assignment_record( + principal=PRINCIPAL, + command=_assignment_command(), + purpose_code="workforce_admin", + policy=_policy( + resource_kind="assignment_record", + purpose_code="workforce_admin", + operation_code="create_record", + scope_code="orgmetra.people.write", + field_name="assignment_record", + ), + mutation_port=port, + ) + self._mutate_retained_result(source, "assignment_record_id") + self.assertEqual(returned.assignment_record_id, ASSIGNMENT) + + def test_hire_result_is_detached_from_port_alias(self) -> None: + """A port must not rewrite accepted hire identities after service return.""" + source = HireAcceptanceResult( + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + candidate_worker_conversion_record_id=CONVERSION, + ) + port = _RetainedHireResultPort(source) + returned = accept_confirmed_hire( + principal=PRINCIPAL, + command=_hire_command(), + purpose_code="candidate_hire", + policy=_policy( + resource_kind="selection_decision", + purpose_code="candidate_hire", + operation_code="materialize_worker", + scope_code="orgmetra.people.materialize_worker", + field_name="candidate_worker_conversion", + ), + mutation_port=port, + ) + self._mutate_retained_result(source, "person_record_id") + self._mutate_retained_result(source, "employment_record_id") + self._mutate_retained_result(source, "candidate_worker_conversion_record_id") + self.assertEqual(returned.person_record_id, PERSON) + self.assertEqual(returned.employment_record_id, EMPLOYMENT) + self.assertEqual(returned.candidate_worker_conversion_record_id, CONVERSION) + + +if __name__ == "__main__": + unittest.main() From 6f3a54b14ee448dcb7719c98a2640ed0990a493b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:32:44 +0900 Subject: [PATCH 113/269] fix(people): detach generic mutation results from port aliases --- .../src/orgmetra_people_api/mutations.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 708aab5c7..a74f522a2 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -310,8 +310,9 @@ class EmploymentMutationResult: replay_command_digest: str | None = None def __post_init__(self) -> None: - """Prevent malformed persistence results from crossing the service boundary.""" - _validate_operational_uuid("employment_record_id", self.employment_record_id) + """Validate and detach persistence result identity from adapter-owned aliases.""" + identity = _validate_operational_uuid("employment_record_id", self.employment_record_id) + object.__setattr__(self, "employment_record_id", UUID(int=identity)) _validate_replay_command_digest(self.replay_command_digest) @@ -323,8 +324,9 @@ class PositionMutationResult: replay_command_digest: str | None = None def __post_init__(self) -> None: - """Prevent malformed persistence results from crossing the service boundary.""" - _validate_operational_uuid("position_record_id", self.position_record_id) + """Validate and detach persistence result identity from adapter-owned aliases.""" + identity = _validate_operational_uuid("position_record_id", self.position_record_id) + object.__setattr__(self, "position_record_id", UUID(int=identity)) _validate_replay_command_digest(self.replay_command_digest) @@ -336,8 +338,9 @@ class AssignmentMutationResult: replay_command_digest: str | None = None def __post_init__(self) -> None: - """Prevent malformed persistence results from crossing the service boundary.""" - _validate_operational_uuid("assignment_record_id", self.assignment_record_id) + """Validate and detach persistence result identity from adapter-owned aliases.""" + identity = _validate_operational_uuid("assignment_record_id", self.assignment_record_id) + object.__setattr__(self, "assignment_record_id", UUID(int=identity)) _validate_replay_command_digest(self.replay_command_digest) @@ -423,7 +426,7 @@ def create_employment_record( result = port.create_employment(command=command, authorization=authorization) if type(result) is not EmploymentMutationResult: raise TypeError("mutation_port must return EmploymentMutationResult") - EmploymentMutationResult.__post_init__(result) + result = replace(result) _require_result_identity_or_replay( result_record_id=result.employment_record_id, expected_record_id=expected_employment_record_id, @@ -463,7 +466,7 @@ def create_position_record( result = port.create_position(command=command, authorization=authorization) if type(result) is not PositionMutationResult: raise TypeError("mutation_port must return PositionMutationResult") - PositionMutationResult.__post_init__(result) + result = replace(result) _require_result_identity_or_replay( result_record_id=result.position_record_id, expected_record_id=expected_position_record_id, @@ -503,7 +506,7 @@ def create_assignment_record( result = port.create_assignment(command=command, authorization=authorization) if type(result) is not AssignmentMutationResult: raise TypeError("mutation_port must return AssignmentMutationResult") - AssignmentMutationResult.__post_init__(result) + result = replace(result) _require_result_identity_or_replay( result_record_id=result.assignment_record_id, expected_record_id=expected_assignment_record_id, From 41829e0971a79012a2cd3723b8c355baa5acac3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:33:10 +0900 Subject: [PATCH 114/269] fix(people): detach confirmed-hire results from port aliases --- services/people-api/src/orgmetra_people_api/hire.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 7ee73f45b..93d68d131 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -115,13 +115,14 @@ class HireAcceptanceResult: candidate_worker_conversion_record_id: UUID def __post_init__(self) -> None: - """Prevent malformed persistence results from crossing the service boundary.""" + """Validate and detach persistence result identities from adapter-owned aliases.""" for field_name in ( "person_record_id", "employment_record_id", "candidate_worker_conversion_record_id", ): - _validate_operational_uuid(field_name, getattr(self, field_name)) + identity = _validate_operational_uuid(field_name, getattr(self, field_name)) + object.__setattr__(self, field_name, UUID(int=identity)) @runtime_checkable @@ -177,7 +178,7 @@ def accept_confirmed_hire( result = mutation_port.accept_hire(command=command, authorization=authorization) if type(result) is not HireAcceptanceResult: raise TypeError("mutation_port must return HireAcceptanceResult") - HireAcceptanceResult.__post_init__(result) + result = replace(result) if ( result.person_record_id != expected_person_record_id or result.employment_record_id != expected_employment_record_id From a9848fbbd335e0f67fee4f4afd63062ff4b6d557 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:04:32 +0900 Subject: [PATCH 115/269] test(people): bind replay verification to pre-port semantics --- ...eople_mutation_replay_semantic_snapshot.py | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py diff --git a/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py b/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py new file mode 100644 index 000000000..ba02c086e --- /dev/null +++ b/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py @@ -0,0 +1,124 @@ +"""Pre-port semantic snapshot regressions for People mutation replay receipts.""" + +from __future__ import annotations + +import unittest +from uuid import UUID + +from orgmetra_people_api.mutations import ( + AssignmentMutationCommand, + AssignmentMutationResult, + EmploymentMutationCommand, + EmploymentMutationResult, + PeopleMutationIntegrityError, + PositionMutationCommand, + PositionMutationResult, + create_assignment_record, + create_employment_record, + create_position_record, + mutation_command_digest, +) +from test_people_mutations import ( + PRINCIPAL, + assignment_command, + assignment_policy, + employment_command, + employment_policy, + position_command, + position_policy, +) + +OTHER = UUID("0198a412-b300-7000-8000-000000000099") + + +class _SemanticSwitchingReplayPort: + """Mutate the received command before manufacturing matching replay evidence.""" + + def create_employment( + self, + *, + command: EmploymentMutationCommand, + authorization: object, + ) -> EmploymentMutationResult: + """Switch Person semantics while retaining a syntactically valid replay receipt.""" + object.__setattr__(command, "person_record_id", OTHER) + return EmploymentMutationResult( + employment_record_id=OTHER, + replay_command_digest=mutation_command_digest( + command=command, + authorization=authorization, # type: ignore[arg-type] + ), + ) + + def create_position( + self, + *, + command: PositionMutationCommand, + authorization: object, + ) -> PositionMutationResult: + """Switch Job semantics while retaining a syntactically valid replay receipt.""" + object.__setattr__(command, "job_profile_id", OTHER) + return PositionMutationResult( + position_record_id=OTHER, + replay_command_digest=mutation_command_digest( + command=command, + authorization=authorization, # type: ignore[arg-type] + ), + ) + + def create_assignment( + self, + *, + command: AssignmentMutationCommand, + authorization: object, + ) -> AssignmentMutationResult: + """Switch Position semantics while retaining a syntactically valid replay receipt.""" + object.__setattr__(command, "position_record_id", OTHER) + return AssignmentMutationResult( + assignment_record_id=OTHER, + replay_command_digest=mutation_command_digest( + command=command, + authorization=authorization, # type: ignore[arg-type] + ), + ) + + +class PeopleMutationReplaySemanticSnapshotTests(unittest.TestCase): + """Require replay verification to use semantics fixed before executable port work.""" + + def test_employment_replay_digest_cannot_follow_port_mutated_semantics(self) -> None: + """Employment replay evidence remains bound to the authorized Person semantics.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "replay evidence does not match command"): + create_employment_record( + principal=PRINCIPAL, + command=employment_command(), + purpose_code="workforce_admin", + policy=employment_policy(), + mutation_port=_SemanticSwitchingReplayPort(), + ) + + def test_position_replay_digest_cannot_follow_port_mutated_semantics(self) -> None: + """Position replay evidence remains bound to the authorized Job semantics.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "replay evidence does not match command"): + create_position_record( + principal=PRINCIPAL, + command=position_command(), + purpose_code="job_architecture_admin", + policy=position_policy(), + mutation_port=_SemanticSwitchingReplayPort(), + ) + + def test_assignment_replay_digest_cannot_follow_port_mutated_semantics(self) -> None: + """Assignment replay evidence remains bound to the authorized Position semantics.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "replay evidence does not match command"): + create_assignment_record( + principal=PRINCIPAL, + command=assignment_command(), + purpose_code="workforce_admin", + policy=assignment_policy(), + mutation_port=_SemanticSwitchingReplayPort(), + ) + + +if __name__ == "__main__": + unittest.main() From ee7b03d912d65ace2fe400bf0fdbc70f3afe5247 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:09:19 +0900 Subject: [PATCH 116/269] fix(people): bind replay checks to pre-port command semantics --- services/people-api/src/orgmetra_people_api/mutations.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index a74f522a2..bef3bb6fb 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -423,7 +423,8 @@ def create_employment_record( requested_fields=_EMPLOYMENT_FIELDS, policy=policy, ) - result = port.create_employment(command=command, authorization=authorization) + port_command = replace(command) + result = port.create_employment(command=port_command, authorization=authorization) if type(result) is not EmploymentMutationResult: raise TypeError("mutation_port must return EmploymentMutationResult") result = replace(result) @@ -463,7 +464,8 @@ def create_position_record( requested_fields=_POSITION_FIELDS, policy=policy, ) - result = port.create_position(command=command, authorization=authorization) + port_command = replace(command) + result = port.create_position(command=port_command, authorization=authorization) if type(result) is not PositionMutationResult: raise TypeError("mutation_port must return PositionMutationResult") result = replace(result) @@ -503,7 +505,8 @@ def create_assignment_record( requested_fields=_ASSIGNMENT_FIELDS, policy=policy, ) - result = port.create_assignment(command=command, authorization=authorization) + port_command = replace(command) + result = port.create_assignment(command=port_command, authorization=authorization) if type(result) is not AssignmentMutationResult: raise TypeError("mutation_port must return AssignmentMutationResult") result = replace(result) From d9cb26e314a3d67bf70d7f59f80e06278f82e39a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:12:50 +0900 Subject: [PATCH 117/269] test(people): bind replay receipt to pre-port authorization evidence --- ...eople_mutation_replay_semantic_snapshot.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py b/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py index ba02c086e..9ad2f02cb 100644 --- a/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py +++ b/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py @@ -83,6 +83,46 @@ def create_assignment( ) +class _AuthorizationSwitchingReplayPort: + """Rewrite authorization evidence before manufacturing an otherwise matching replay receipt.""" + + def create_employment( + self, + *, + command: EmploymentMutationCommand, + authorization: object, + ) -> EmploymentMutationResult: + """Switch actor evidence after authorization and bind the receipt to the changed decision.""" + object.__setattr__(authorization, "actor_reference", "keyverse_subject:substituted-operator") + return EmploymentMutationResult( + employment_record_id=OTHER, + replay_command_digest=mutation_command_digest( + command=command, + authorization=authorization, # type: ignore[arg-type] + ), + ) + + def create_position( + self, + *, + command: PositionMutationCommand, + authorization: object, + ) -> PositionMutationResult: + """Reject unrelated Position work while satisfying the mutation-port protocol.""" + del command, authorization + raise AssertionError("position mutation is outside this regression") + + def create_assignment( + self, + *, + command: AssignmentMutationCommand, + authorization: object, + ) -> AssignmentMutationResult: + """Reject unrelated Assignment work while satisfying the mutation-port protocol.""" + del command, authorization + raise AssertionError("assignment mutation is outside this regression") + + class PeopleMutationReplaySemanticSnapshotTests(unittest.TestCase): """Require replay verification to use semantics fixed before executable port work.""" @@ -119,6 +159,17 @@ def test_assignment_replay_digest_cannot_follow_port_mutated_semantics(self) -> mutation_port=_SemanticSwitchingReplayPort(), ) + def test_replay_digest_cannot_follow_port_mutated_authorization(self) -> None: + """Replay evidence remains bound to the decision returned by purpose-bound authorization.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "replay evidence does not match command"): + create_employment_record( + principal=PRINCIPAL, + command=employment_command(), + purpose_code="workforce_admin", + policy=employment_policy(), + mutation_port=_AuthorizationSwitchingReplayPort(), + ) + if __name__ == "__main__": unittest.main() From 41a35a5b026d83cefae8d2ca6b2415bf36086d6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 14:14:42 +0900 Subject: [PATCH 118/269] fix(people): freeze replay evidence before executable persistence --- .../src/orgmetra_people_api/mutations.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index bef3bb6fb..8ffbdb1e3 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -385,13 +385,12 @@ def _require_result_identity_or_replay( result_record_id: UUID, expected_record_id: UUID, replay_command_digest: str | None, - command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, - authorization: AuthorizationDecision, + expected_replay_command_digest: str, result_name: str, ) -> None: - """Accept a foreign identity only with replay evidence bound to this semantic command.""" + """Accept a foreign identity only with replay evidence bound before executable persistence.""" if replay_command_digest is not None: - if replay_command_digest != mutation_command_digest(command=command, authorization=authorization): + if replay_command_digest != expected_replay_command_digest: raise PeopleMutationIntegrityError(f"{result_name} replay evidence does not match command") return if result_record_id != expected_record_id: @@ -423,6 +422,7 @@ def create_employment_record( requested_fields=_EMPLOYMENT_FIELDS, policy=policy, ) + expected_replay_command_digest = mutation_command_digest(command=command, authorization=authorization) port_command = replace(command) result = port.create_employment(command=port_command, authorization=authorization) if type(result) is not EmploymentMutationResult: @@ -432,8 +432,7 @@ def create_employment_record( result_record_id=result.employment_record_id, expected_record_id=expected_employment_record_id, replay_command_digest=result.replay_command_digest, - command=command, - authorization=authorization, + expected_replay_command_digest=expected_replay_command_digest, result_name="employment", ) return result @@ -464,6 +463,7 @@ def create_position_record( requested_fields=_POSITION_FIELDS, policy=policy, ) + expected_replay_command_digest = mutation_command_digest(command=command, authorization=authorization) port_command = replace(command) result = port.create_position(command=port_command, authorization=authorization) if type(result) is not PositionMutationResult: @@ -473,8 +473,7 @@ def create_position_record( result_record_id=result.position_record_id, expected_record_id=expected_position_record_id, replay_command_digest=result.replay_command_digest, - command=command, - authorization=authorization, + expected_replay_command_digest=expected_replay_command_digest, result_name="position", ) return result @@ -505,6 +504,7 @@ def create_assignment_record( requested_fields=_ASSIGNMENT_FIELDS, policy=policy, ) + expected_replay_command_digest = mutation_command_digest(command=command, authorization=authorization) port_command = replace(command) result = port.create_assignment(command=port_command, authorization=authorization) if type(result) is not AssignmentMutationResult: @@ -514,8 +514,7 @@ def create_assignment_record( result_record_id=result.assignment_record_id, expected_record_id=expected_assignment_record_id, replay_command_digest=result.replay_command_digest, - command=command, - authorization=authorization, + expected_replay_command_digest=expected_replay_command_digest, result_name="assignment", ) return result From 1cdaa063da01628cfe9d5b1e7b8590bef5609c06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 16:21:28 +0900 Subject: [PATCH 119/269] test(people): expose assignment conflict serialization gap --- .../test_assignment_conflict_serialization.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 services/people-api/tests/test_assignment_conflict_serialization.py diff --git a/services/people-api/tests/test_assignment_conflict_serialization.py b/services/people-api/tests/test_assignment_conflict_serialization.py new file mode 100644 index 000000000..07c9e81ec --- /dev/null +++ b/services/people-api/tests/test_assignment_conflict_serialization.py @@ -0,0 +1,16 @@ +"""Regression contract for serializable Assignment portfolio/capacity validation.""" + +from __future__ import annotations + +from orgmetra_people_api.postgres_mutations import ( + _EXISTING_ASSIGNMENTS_SQL, + _NAMED_EMPLOYMENT_VERSIONS_SQL, + _NAMED_POSITION_VERSIONS_SQL, +) + + +def test_assignment_snapshot_locks_both_conflict_roots_before_reading_allocations() -> None: + """Serialize writes sharing an employment or position before checking FTE totals.""" + assert "FOR UPDATE OF employment" in _NAMED_EMPLOYMENT_VERSIONS_SQL + assert "FOR UPDATE OF position" in _NAMED_POSITION_VERSIONS_SQL + assert "FROM public.assignment_record AS assignment" in _EXISTING_ASSIGNMENTS_SQL From f6a7836272c6c8c5c7882c783f61ab3b3a7899cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 16:23:26 +0900 Subject: [PATCH 120/269] fix(people): serialize assignment employment portfolio writes --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index b90ebcf83..a7b50d4b9 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -158,6 +158,7 @@ AND version.employment_record_id = employment.employment_record_id WHERE employment.tenant_record_id = %s AND employment.employment_record_id = %s +FOR UPDATE OF employment """.strip() _NAMED_POSITION_VERSIONS_SQL = """ @@ -721,7 +722,7 @@ def create_position( rows = _unpack_fixed_rows( cursor.fetchmany(2), row_width=3, - error_message="position parent row is invalid", + error_message="position parent row is invalid shape", ) if not rows: raise PeopleMutationNotFound("organization unit or job profile was not found") From 06744f4bced57175af2b0819dae0a7d17070862c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 16:25:17 +0900 Subject: [PATCH 121/269] fix(people): restore unrelated position-parent diagnostic --- .../people-api/src/orgmetra_people_api/postgres_mutations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index a7b50d4b9..7ef3612c7 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -722,7 +722,7 @@ def create_position( rows = _unpack_fixed_rows( cursor.fetchmany(2), row_width=3, - error_message="position parent row is invalid shape", + error_message="position parent row is invalid", ) if not rows: raise PeopleMutationNotFound("organization unit or job profile was not found") From 19c6c7e2eb8c05d8317635a0fa66768cc904088b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 17:10:23 +0900 Subject: [PATCH 122/269] fix(people): remove redundant assignment root lock --- .../orgmetra_people_api/postgres_mutations.py | 1 - .../test_assignment_conflict_serialization.py | 16 ---------------- 2 files changed, 17 deletions(-) delete mode 100644 services/people-api/tests/test_assignment_conflict_serialization.py diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 7ef3612c7..b90ebcf83 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -158,7 +158,6 @@ AND version.employment_record_id = employment.employment_record_id WHERE employment.tenant_record_id = %s AND employment.employment_record_id = %s -FOR UPDATE OF employment """.strip() _NAMED_POSITION_VERSIONS_SQL = """ diff --git a/services/people-api/tests/test_assignment_conflict_serialization.py b/services/people-api/tests/test_assignment_conflict_serialization.py deleted file mode 100644 index 07c9e81ec..000000000 --- a/services/people-api/tests/test_assignment_conflict_serialization.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Regression contract for serializable Assignment portfolio/capacity validation.""" - -from __future__ import annotations - -from orgmetra_people_api.postgres_mutations import ( - _EXISTING_ASSIGNMENTS_SQL, - _NAMED_EMPLOYMENT_VERSIONS_SQL, - _NAMED_POSITION_VERSIONS_SQL, -) - - -def test_assignment_snapshot_locks_both_conflict_roots_before_reading_allocations() -> None: - """Serialize writes sharing an employment or position before checking FTE totals.""" - assert "FOR UPDATE OF employment" in _NAMED_EMPLOYMENT_VERSIONS_SQL - assert "FOR UPDATE OF position" in _NAMED_POSITION_VERSIONS_SQL - assert "FROM public.assignment_record AS assignment" in _EXISTING_ASSIGNMENTS_SQL From fb28b01915cd8bf233260cfa3c4b562879f9ff7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 18:15:14 +0900 Subject: [PATCH 123/269] test(people): execute real Assignment lock interleavings --- ...tgres_assignment_concurrency_acceptance.py | 773 ++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py new file mode 100644 index 000000000..b399f1945 --- /dev/null +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -0,0 +1,773 @@ +"""Real PostgreSQL interleavings for governed Assignment conflict domains. + +The acceptance deliberately drives ``PostgresPeopleMutationPort`` through a +small libpq DB-API boundary instead of mocking cursors. Each case runs against +an isolated PostgreSQL container and holds the first writer immediately before +COMMIT, so the second writer must cross the production row-lock boundary. The +wait is observed from ``pg_stat_activity``/``pg_blocking_pids``; sleeps never +establish the correctness ordering. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import ctypes +import ctypes.util +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +import os +from pathlib import Path +import re +import subprocess +import threading +import time +from typing import Iterator, Sequence +from uuid import UUID, uuid4 + +import pytest + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.mutations import AssignmentMutationCommand, PeopleMutationIntegrityError +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_TENANT = UUID("10000000-0000-7000-8000-000000000001") +_PERSON_ONE = UUID("10000000-0000-7000-8000-000000000101") +_PERSON_TWO = UUID("10000000-0000-7000-8000-000000000102") +_EMPLOYMENT_ONE = UUID("10000000-0000-7000-8000-000000000111") +_EMPLOYMENT_TWO = UUID("10000000-0000-7000-8000-000000000112") +_POSITION_ONE = UUID("10000000-0000-7000-8000-000000000121") +_POSITION_TWO = UUID("10000000-0000-7000-8000-000000000122") +_ASSIGNMENT_EFFECTIVE_FROM = date(2026, 8, 18) +_MIGRATIONS = ( + "database/migrations/0001_foundation_schema.sql", + "database/migrations/0002_sealed_evidence_digest.sql", + "database/migrations/0003_audit_outbox_persistence.sql", + "database/migrations/0004_outbox_delivery_claim.sql", + "database/migrations/0005_outbox_delivery_finalization.sql", + "database/migrations/0006_outbox_delivery_dead_letter.sql", + "database/migrations/0007_outbox_retry_exhaustion.sql", + "database/migrations/0008_audit_outbox_review_hardening.sql", + "database/migrations/0009_candidate_worker_conversion_governance.sql", + "database/migrations/0012_people_mutation_idempotency.sql", +) + +_PGRES_COMMAND_OK = 1 +_PGRES_TUPLES_OK = 2 +_OID_BOOL = 16 +_OID_INT8 = 20 +_OID_INT2 = 21 +_OID_INT4 = 23 +_OID_FLOAT4 = 700 +_OID_FLOAT8 = 701 +_OID_DATE = 1082 +_OID_TIMESTAMP = 1114 +_OID_TIMESTAMPTZ = 1184 +_OID_NUMERIC = 1700 +_OID_UUID = 2950 +_PARAMETER = re.compile(r"%s") + + +def _load_libpq() -> ctypes.CDLL: + """Load the system libpq already required by the repository's psql contracts.""" + candidate = ctypes.util.find_library("pq") + if candidate is None: + raise RuntimeError("libpq is required for PostgreSQL Assignment acceptance") + library = ctypes.CDLL(candidate) + library.PQconnectdb.argtypes = [ctypes.c_char_p] + library.PQconnectdb.restype = ctypes.c_void_p + library.PQstatus.argtypes = [ctypes.c_void_p] + library.PQstatus.restype = ctypes.c_int + library.PQerrorMessage.argtypes = [ctypes.c_void_p] + library.PQerrorMessage.restype = ctypes.c_char_p + library.PQfinish.argtypes = [ctypes.c_void_p] + library.PQbackendPID.argtypes = [ctypes.c_void_p] + library.PQbackendPID.restype = ctypes.c_int + library.PQexecParams.argtypes = [ + ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_int, + ctypes.POINTER(ctypes.c_uint), + ctypes.POINTER(ctypes.c_char_p), + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_int), + ctypes.c_int, + ] + library.PQexecParams.restype = ctypes.c_void_p + library.PQresultStatus.argtypes = [ctypes.c_void_p] + library.PQresultStatus.restype = ctypes.c_int + library.PQresultErrorMessage.argtypes = [ctypes.c_void_p] + library.PQresultErrorMessage.restype = ctypes.c_char_p + library.PQntuples.argtypes = [ctypes.c_void_p] + library.PQntuples.restype = ctypes.c_int + library.PQnfields.argtypes = [ctypes.c_void_p] + library.PQnfields.restype = ctypes.c_int + library.PQftype.argtypes = [ctypes.c_void_p, ctypes.c_int] + library.PQftype.restype = ctypes.c_uint + library.PQgetisnull.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] + library.PQgetisnull.restype = ctypes.c_int + library.PQgetvalue.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] + library.PQgetvalue.restype = ctypes.c_char_p + library.PQclear.argtypes = [ctypes.c_void_p] + return library + + +_LIBPQ = _load_libpq() + + +def _parameter_text(value: object) -> bytes | None: + """Serialize the exact scalar parameter types used by People PostgreSQL writes.""" + if value is None: + return None + if type(value) is UUID: + return str(value).encode() + if type(value) is datetime: + return value.isoformat().encode() + if type(value) is date: + return value.isoformat().encode() + if type(value) is Decimal: + return format(value, "f").encode() + if type(value) is bool: + return ("true" if value else "false").encode() + if type(value) in (str, int, float): + return str(value).encode() + raise TypeError(f"unsupported libpq acceptance parameter: {type(value).__name__}") + + +def _decode_field(oid: int, raw: bytes) -> object: + """Decode PostgreSQL text results into the exact runtime scalars the adapter validates.""" + text = raw.decode() + if oid == _OID_UUID: + return UUID(text) + if oid == _OID_DATE: + return date.fromisoformat(text) + if oid in (_OID_TIMESTAMP, _OID_TIMESTAMPTZ): + return datetime.fromisoformat(text) + if oid == _OID_NUMERIC: + return Decimal(text) + if oid in (_OID_INT2, _OID_INT4, _OID_INT8): + return int(text) + if oid in (_OID_FLOAT4, _OID_FLOAT8): + return float(text) + if oid == _OID_BOOL: + return text == "t" + return text + + +def _bind_parameters(sql: str, parameters: Sequence[object]) -> tuple[str, list[bytes | None]]: + """Translate DB-API ``%s`` markers to libpq positional parameters without interpolation.""" + counter = 0 + + def replace_marker(_: re.Match[str]) -> str: + nonlocal counter + counter += 1 + return f"${counter}" + + translated = _PARAMETER.sub(replace_marker, sql) + if counter != len(parameters): + raise AssertionError(f"SQL expected {counter} parameters, received {len(parameters)}") + return translated, [_parameter_text(parameter) for parameter in parameters] + + +@dataclass(slots=True) +class _CommitBarrier: + """Hold one real transaction after all adapter writes but before COMMIT.""" + + ready: threading.Event + release: threading.Event + + @classmethod + def create(cls) -> "_CommitBarrier": + """Return a fresh one-shot commit barrier.""" + return cls(threading.Event(), threading.Event()) + + +class _LibpqCursor: + """Minimal DB-API cursor over one libpq connection for production adapter acceptance.""" + + def __init__(self, connection: "_LibpqConnection") -> None: + self._connection = connection + self._rows: list[tuple[object, ...]] = [] + self._offset = 0 + + def __enter__(self) -> "_LibpqCursor": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + + def execute(self, sql: str, parameters: Sequence[object] | None = None) -> None: + self._rows = self._connection.execute(sql, () if parameters is None else parameters) + self._offset = 0 + + def fetchmany(self, size: int) -> list[tuple[object, ...]]: + rows = self._rows[self._offset : self._offset + size] + self._offset += len(rows) + return rows + + def fetchall(self) -> list[tuple[object, ...]]: + rows = self._rows[self._offset :] + self._offset = len(self._rows) + return rows + + +class _LibpqConnection: + """One real libpq transaction with psycopg-compatible context semantics.""" + + def __init__( + self, + database_url: str, + *, + application_name: str, + barrier: _CommitBarrier | None = None, + ) -> None: + separator = "&" if "?" in database_url else "?" + connection_url = f"{database_url}{separator}application_name={application_name}" + handle = _LIBPQ.PQconnectdb(connection_url.encode()) + if not handle: + raise RuntimeError("libpq returned a null PostgreSQL connection") + self._handle = handle + self._barrier = barrier + self.closed = False + if _LIBPQ.PQstatus(handle) != 0: + message = _LIBPQ.PQerrorMessage(handle).decode().strip() + _LIBPQ.PQfinish(handle) + self.closed = True + raise RuntimeError(f"PostgreSQL connection failed: {message}") + self.backend_pid = int(_LIBPQ.PQbackendPID(handle)) + + def __enter__(self) -> "_LibpqConnection": + self.execute("BEGIN", ()) + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc, traceback + try: + if exc_type is None: + if self._barrier is not None: + self._barrier.ready.set() + if not self._barrier.release.wait(timeout=30): + self.execute("ROLLBACK", ()) + raise AssertionError("timed out waiting to release the first Assignment transaction") + self.execute("COMMIT", ()) + else: + self.execute("ROLLBACK", ()) + finally: + _LIBPQ.PQfinish(self._handle) + self.closed = True + + def cursor(self) -> _LibpqCursor: + return _LibpqCursor(self) + + def execute(self, sql: str, parameters: Sequence[object]) -> list[tuple[object, ...]]: + translated, encoded = _bind_parameters(sql, parameters) + values = None + if encoded: + values = (ctypes.c_char_p * len(encoded))( + *[ctypes.c_char_p(value) if value is not None else None for value in encoded] + ) + result = _LIBPQ.PQexecParams( + self._handle, + translated.encode(), + len(encoded), + None, + values, + None, + None, + 0, + ) + if not result: + message = _LIBPQ.PQerrorMessage(self._handle).decode().strip() + raise RuntimeError(f"libpq execution failed without a result: {message}") + try: + status = _LIBPQ.PQresultStatus(result) + if status not in (_PGRES_COMMAND_OK, _PGRES_TUPLES_OK): + message = _LIBPQ.PQresultErrorMessage(result).decode().strip() + raise RuntimeError(f"PostgreSQL statement failed: {message}\nSQL: {translated}") + row_count = _LIBPQ.PQntuples(result) + column_count = _LIBPQ.PQnfields(result) + column_oids = [_LIBPQ.PQftype(result, column) for column in range(column_count)] + rows: list[tuple[object, ...]] = [] + for row_index in range(row_count): + row: list[object] = [] + for column_index, oid in enumerate(column_oids): + if _LIBPQ.PQgetisnull(result, row_index, column_index): + row.append(None) + else: + raw = _LIBPQ.PQgetvalue(result, row_index, column_index) + row.append(_decode_field(oid, raw)) + rows.append(tuple(row)) + return rows + finally: + _LIBPQ.PQclear(result) + + +class _ConnectionFactory: + """Capture backend identity and cleanup for one production Assignment writer.""" + + def __init__( + self, + database_url: str, + *, + application_name: str, + barrier: _CommitBarrier | None = None, + ) -> None: + self._database_url = database_url + self._application_name = application_name + self._barrier = barrier + self.connected = threading.Event() + self.connections: list[_LibpqConnection] = [] + + def __call__(self) -> _LibpqConnection: + connection = _LibpqConnection( + self._database_url, + application_name=self._application_name, + barrier=self._barrier, + ) + self.connections.append(connection) + self.connected.set() + return connection + + @property + def backend_pid(self) -> int: + if not self.connections: + raise AssertionError("writer did not create a PostgreSQL connection") + return self.connections[-1].backend_pid + + +@dataclass(slots=True) +class _WriterOutcome: + result_id: UUID | None = None + error: BaseException | None = None + + +def _psql(database_url: str, sql: str) -> str: + """Execute setup/observation SQL with the repository's existing psql boundary.""" + completed = subprocess.run( + ["psql", database_url, "-X", "-v", "ON_ERROR_STOP=1", "-Atqc", sql], + cwd=_REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def _apply_migrations(database_url: str) -> None: + """Apply the exact migration stack required by the production People mutation path.""" + for migration in _MIGRATIONS: + subprocess.run( + ["psql", database_url, "-X", "-v", "ON_ERROR_STOP=1", "-f", migration], + cwd=_REPO_ROOT, + check=True, + stdout=subprocess.DEVNULL, + ) + + +@contextmanager +def _isolated_postgres() -> Iterator[str]: + """Yield one isolated PostgreSQL 16 database using the Foundation image contract.""" + image = os.environ.get("ORGMETRA_POSTGRES_IMAGE") + if not image: + raise AssertionError("ORGMETRA_POSTGRES_IMAGE is required for fail-closed PostgreSQL acceptance") + container_name = f"orgmetra-assignment-concurrency-{uuid4().hex}" + subprocess.run( + [ + "docker", + "run", + "--detach", + "--name", + container_name, + "--env", + "POSTGRES_USER=orgmetra", + "--env", + "POSTGRES_PASSWORD=orgmetra", + "--env", + "POSTGRES_DB=orgmetra", + "--publish", + "127.0.0.1::5432", + image, + ], + check=True, + stdout=subprocess.DEVNULL, + ) + try: + binding = subprocess.run( + ["docker", "port", container_name, "5432/tcp"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + port = binding.rsplit(":", 1)[1] + database_url = f"postgresql://orgmetra:orgmetra@127.0.0.1:{port}/orgmetra" + for _ in range(60): + ready = subprocess.run( + ["psql", database_url, "-X", "-Atqc", "SELECT 1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if ready.returncode == 0: + break + time.sleep(0.1) + else: + logs = subprocess.run( + ["docker", "logs", container_name], + capture_output=True, + text=True, + ).stdout + raise AssertionError(f"PostgreSQL acceptance container did not become ready:\n{logs}") + _apply_migrations(database_url) + yield database_url + finally: + subprocess.run( + ["docker", "rm", "--force", container_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def _conversion_event(*, audit_id: UUID, conversion_id: UUID, evidence_set_id: UUID) -> str: + """Return one canonical, non-PII conversion event for a repository fixture.""" + return ( + '{"data":{"high_impact":true,"result_code":"worker_created"},' + '"datacontenttype":"application/json",' + f'"id":"{audit_id}",' + '"orgmetraactor":"keyverse_subject:concurrency-fixture",' + '"orgmetraconfirmation":"confirmation:concurrency-fixture",' + f'"orgmetraevidence":"decision_evidence_set:{evidence_set_id}",' + '"orgmetrapurpose":"talent_acquisition",' + '"orgmetrareason":"candidate_hire_confirmed",' + f'"orgmetratenant":"{_TENANT}",' + '"source":"urn:orgmetra:talent_core","specversion":"1.0",' + f'"subject":"candidate_worker_conversion_record:{conversion_id}",' + '"time":"2026-08-17T05:01:00Z",' + '"type":"orgmetra.candidate.worker_converted"}' + ) + + +def _seed_fixture(database_url: str, *, people: int, positions: int) -> None: + """Seed right-cleared structural fixtures needed by Assignment acceptance.""" + organization = UUID("10000000-0000-7000-8000-000000000131") + job = UUID("10000000-0000-7000-8000-000000000141") + person_ids = (_PERSON_ONE, _PERSON_TWO)[:people] + employment_ids = (_EMPLOYMENT_ONE, _EMPLOYMENT_TWO)[:people] + position_ids = (_POSITION_ONE, _POSITION_TWO)[:positions] + statements = [ + f"INSERT INTO tenant_record (tenant_record_id, tenant_reference) VALUES ('{_TENANT}', 'concurrency_fixture');", + *[ + "INSERT INTO person_record (tenant_record_id, person_record_id, recorded_from) " + f"VALUES ('{_TENANT}', '{person_id}', TIMESTAMPTZ '2026-08-17 04:50:00+00');" + for person_id in person_ids + ], + *[ + "INSERT INTO employment_record (tenant_record_id, employment_record_id, person_record_id, recorded_from) " + f"VALUES ('{_TENANT}', '{employment_id}', '{person_id}', TIMESTAMPTZ '2026-08-17 04:55:00+00');" + for person_id, employment_id in zip(person_ids, employment_ids, strict=True) + ], + *[ + "INSERT INTO employment_record_version " + "(tenant_record_id, employment_record_version_id, employment_record_id, employment_status_code, " + "employment_concurrency_code, effective_from, recorded_from) " + f"VALUES ('{_TENANT}', '10000000-0000-7000-8000-{200 + index:012d}', '{employment_id}', " + "'active', 'concurrent', DATE '2026-08-17', TIMESTAMPTZ '2026-08-17 04:55:00+00');" + for index, employment_id in enumerate(employment_ids, start=1) + ], + "INSERT INTO organization_unit (tenant_record_id, organization_unit_id, recorded_from) " + f"VALUES ('{_TENANT}', '{organization}', TIMESTAMPTZ '2026-08-17 04:40:00+00');", + "INSERT INTO job_profile (tenant_record_id, job_profile_id, recorded_from) " + f"VALUES ('{_TENANT}', '{job}', TIMESTAMPTZ '2026-08-17 04:40:00+00');", + *[ + "INSERT INTO position_record " + "(tenant_record_id, position_record_id, organization_unit_id, job_profile_id, recorded_from) " + f"VALUES ('{_TENANT}', '{position_id}', '{organization}', '{job}', TIMESTAMPTZ '2026-08-17 04:50:00+00');" + for position_id in position_ids + ], + *[ + "INSERT INTO position_record_version " + "(tenant_record_id, position_record_version_id, position_record_id, position_status_code, " + "effective_from, recorded_from) " + f"VALUES ('{_TENANT}', '10000000-0000-7000-8000-{300 + index:012d}', '{position_id}', " + "'open', DATE '2026-08-17', TIMESTAMPTZ '2026-08-17 04:50:00+00');" + for index, position_id in enumerate(position_ids, start=1) + ], + ] + for index, (person_id, employment_id) in enumerate(zip(person_ids, employment_ids, strict=True), start=1): + candidate_id = UUID(f"10000000-0000-7000-8000-{400 + index:012d}") + evidence_set_id = UUID(f"10000000-0000-7000-8000-{500 + index:012d}") + evidence_member_id = UUID(f"10000000-0000-7000-8000-{600 + index:012d}") + decision_id = UUID(f"10000000-0000-7000-8000-{700 + index:012d}") + audit_id = UUID(f"10000000-0000-7000-8000-{800 + index:012d}") + outbox_id = UUID(f"10000000-0000-7000-8000-{900 + index:012d}") + conversion_id = UUID(f"10000000-0000-7000-8000-{1000 + index:012d}") + event = _conversion_event( + audit_id=audit_id, + conversion_id=conversion_id, + evidence_set_id=evidence_set_id, + ).replace("'", "''") + statements.extend( + [ + "INSERT INTO candidate_profile " + "(tenant_record_id, candidate_profile_id, application_status_code, recorded_from) " + f"VALUES ('{_TENANT}', '{candidate_id}', 'offer', TIMESTAMPTZ '2026-08-17 04:45:00+00');", + "INSERT INTO decision_evidence_set " + "(tenant_record_id, decision_evidence_set_id, evidence_set_version_code, digest_algorithm_code, created_at) " + f"VALUES ('{_TENANT}', '{evidence_set_id}', 'concurrency_fixture_v1', 'sha256', " + "TIMESTAMPTZ '2026-08-17 04:56:00+00');", + "INSERT INTO selection_decision_evidence " + "(tenant_record_id, selection_decision_evidence_id, decision_evidence_set_id, " + "evidence_reference, evidence_version_code, recorded_at) " + f"VALUES ('{_TENANT}', '{evidence_member_id}', '{evidence_set_id}', " + f"'structured_interview:concurrency_{index}', 'rubric_v1', TIMESTAMPTZ '2026-08-17 04:57:00+00');", + "INSERT INTO selection_decision " + "(tenant_record_id, selection_decision_id, candidate_profile_id, job_profile_id, " + "decision_evidence_set_id, actor_reference, purpose_code, decision_code, decision_reason, " + "confirmation_reference, decided_at, recorded_at) " + f"VALUES ('{_TENANT}', '{decision_id}', '{candidate_id}', '{job}', '{evidence_set_id}', " + "'keyverse_subject:concurrency-fixture', 'talent_acquisition', 'hire', " + "'Right-cleared concurrency acceptance fixture', 'confirmation:concurrency-fixture', " + "TIMESTAMPTZ '2026-08-17 04:59:00+00', TIMESTAMPTZ '2026-08-17 05:00:00+00');", + "SELECT record_audit_outbox_event(" + f"'{_TENANT}'::uuid, '{audit_id}'::uuid, '{outbox_id}'::uuid, '{event}', " + f"encode(digest(convert_to('{event}', 'UTF8'), 'sha256'), 'hex'), 'talent_event_sink');", + "INSERT INTO candidate_worker_conversion_record " + "(tenant_record_id, candidate_worker_conversion_record_id, candidate_profile_id, person_record_id, " + "employment_record_id, selection_decision_id, audit_event_record_id, effective_from, recorded_from) " + f"VALUES ('{_TENANT}', '{conversion_id}', '{candidate_id}', '{person_id}', '{employment_id}', " + f"'{decision_id}', '{audit_id}', DATE '2026-08-17', TIMESTAMPTZ '2026-08-17 05:02:00+00');", + ] + ) + _psql(database_url, "\n".join(statements)) + + +def _assignment_command( + *, + assignment_id: UUID, + employment_id: UUID, + person_id: UUID, + position_id: UUID, + allocation: Decimal, + suffix: int, +) -> AssignmentMutationCommand: + """Build one governed Assignment command with unique atomic-evidence identities.""" + return AssignmentMutationCommand( + tenant_record_id=_TENANT, + employment_record_id=employment_id, + person_record_id=person_id, + position_record_id=position_id, + assignment_record_id=assignment_id, + audit_event_record_id=UUID(f"10000000-0000-7000-8001-{1000 + suffix:012d}"), + outbox_delivery_record_id=UUID(f"10000000-0000-7000-8001-{2000 + suffix:012d}"), + allocation_ratio=allocation, + effective_from=_ASSIGNMENT_EFFECTIVE_FROM, + confirmation_reference=f"human_confirmation:concurrency-{suffix}", + evidence_version_code="decision_evidence_set:v1", + idempotency_key=f"assignment-concurrency-key-{suffix:02d}", + ) + + +def _authorization(assignment_id: UUID) -> AuthorizationDecision: + """Bind the exact Assignment target to the purpose-scoped production adapter call.""" + return AuthorizationDecision( + allowed=True, + tenant_record_id=_TENANT, + actor_reference="keyverse_subject:concurrency-operator", + resource_reference=f"assignment_record:{assignment_id.hex}", + policy_version_code="people-mutation-v1", + purpose_code="workforce_admin", + operation_code="create_record", + resource_kind="assignment_record", + requested_fields=frozenset({"assignment_record"}), + authorized_fields=frozenset({"assignment_record"}), + reason_code="access_permitted", + next_action="continue", + ) + + +def _run_writer( + *, factory: _ConnectionFactory, command: AssignmentMutationCommand, outcome: _WriterOutcome +) -> None: + """Execute one production Assignment mutation and retain its terminal result.""" + try: + result = PostgresPeopleMutationPort(factory).create_assignment( + command=command, + authorization=_authorization(command.assignment_record_id), + ) + outcome.result_id = result.assignment_record_id + except BaseException as error: + outcome.error = error + + +def _assert_database_lock_wait(database_url: str, *, blocked_pid: int, blocker_pid: int) -> None: + """Prove PostgreSQL itself reports the second backend blocked by the first.""" + for _ in range(300): + observation = _psql( + database_url, + "SELECT concat_ws('|', coalesce(wait_event_type, ''), coalesce(wait_event, ''), " + f"({blocker_pid} = ANY(pg_blocking_pids({blocked_pid})))::text) " + f"FROM pg_stat_activity WHERE pid = {blocked_pid};", + ) + fields = observation.split("|") if observation else [] + if len(fields) == 3 and fields[0] == "Lock" and fields[2] == "true": + return + time.sleep(0.01) + raise AssertionError( + f"backend {blocked_pid} never exposed a DB-visible lock wait on blocker {blocker_pid}" + ) + + +def _assert_atomic_assignment_state( + database_url: str, + *, + employment_id: UUID | None = None, + position_id: UUID | None = None, +) -> None: + """Require one committed Assignment and no audit/outbox/idempotency residue from rejection.""" + predicate = "" + if employment_id is not None: + predicate = f" AND employment_record_id = '{employment_id}'::uuid" + if position_id is not None: + predicate = f" AND position_record_id = '{position_id}'::uuid" + assignment_state = _psql( + database_url, + "SELECT concat_ws('|', count(*), coalesce(sum(allocation_ratio), 0)::text) " + f"FROM assignment_record WHERE tenant_record_id = '{_TENANT}'::uuid{predicate};", + ) + assert assignment_state == "1|0.7500" + evidence_state = _psql( + database_url, + "SELECT concat_ws('|', " + "(SELECT count(*) FROM audit_event_record WHERE canonical_event_json::jsonb ->> 'type' = " + "'orgmetra.people.assignment_created'), " + "(SELECT count(*) FROM outbox_delivery_record AS outbox JOIN audit_event_record AS audit " + "ON audit.tenant_record_id = outbox.tenant_record_id " + "AND audit.audit_event_record_id = outbox.audit_event_record_id " + "WHERE audit.canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.assignment_created'), " + "(SELECT count(*) FROM people_mutation_idempotency_record WHERE command_route = 'assignment-records'));", + ) + assert evidence_state == "1|1|1" + + +def _assert_connection_cleanup(database_url: str, factories: Sequence[_ConnectionFactory]) -> None: + """Require client and server connection cleanup after both concurrent writers terminate.""" + assert all(connection.closed for factory in factories for connection in factory.connections) + active = _psql( + database_url, + "SELECT count(*) FROM pg_stat_activity " + "WHERE application_name LIKE 'orgmetra-assignment-concurrency-writer-%';", + ) + assert active == "0" + + +def _exercise_conflict( + database_url: str, + *, + first_command: AssignmentMutationCommand, + second_command: AssignmentMutationCommand, + expected_error: str, + employment_id: UUID | None = None, + position_id: UUID | None = None, +) -> None: + """Hold writer A at COMMIT, prove writer B waits, then verify post-commit rejection.""" + barrier = _CommitBarrier.create() + first_factory = _ConnectionFactory( + database_url, + application_name="orgmetra-assignment-concurrency-writer-a", + barrier=barrier, + ) + second_factory = _ConnectionFactory( + database_url, + application_name="orgmetra-assignment-concurrency-writer-b", + ) + first_outcome = _WriterOutcome() + second_outcome = _WriterOutcome() + first = threading.Thread( + target=_run_writer, + kwargs={"factory": first_factory, "command": first_command, "outcome": first_outcome}, + daemon=True, + ) + second = threading.Thread( + target=_run_writer, + kwargs={"factory": second_factory, "command": second_command, "outcome": second_outcome}, + daemon=True, + ) + first.start() + assert barrier.ready.wait(timeout=30), "first Assignment writer did not reach its pre-COMMIT barrier" + assert first_outcome.error is None + second.start() + assert second_factory.connected.wait(timeout=30), "second Assignment writer did not connect" + _assert_database_lock_wait( + database_url, + blocked_pid=second_factory.backend_pid, + blocker_pid=first_factory.backend_pid, + ) + barrier.release.set() + first.join(timeout=30) + second.join(timeout=30) + assert not first.is_alive() and not second.is_alive() + assert first_outcome.error is None + assert first_outcome.result_id == first_command.assignment_record_id + assert second_outcome.result_id is None + assert isinstance(second_outcome.error, PeopleMutationIntegrityError) + assert expected_error in str(second_outcome.error) + _assert_atomic_assignment_state( + database_url, + employment_id=employment_id, + position_id=position_id, + ) + _assert_connection_cleanup(database_url, (first_factory, second_factory)) + + +def test_same_employment_different_positions_serializes_on_converted_worker() -> None: + """A stale Employment portfolio cannot cross the converted-worker conflict boundary.""" + with _isolated_postgres() as database_url: + _seed_fixture(database_url, people=1, positions=2) + _exercise_conflict( + database_url, + first_command=_assignment_command( + assignment_id=UUID("10000000-0000-7000-8002-000000000001"), + employment_id=_EMPLOYMENT_ONE, + person_id=_PERSON_ONE, + position_id=_POSITION_ONE, + allocation=Decimal("0.7500"), + suffix=1, + ), + second_command=_assignment_command( + assignment_id=UUID("10000000-0000-7000-8002-000000000002"), + employment_id=_EMPLOYMENT_ONE, + person_id=_PERSON_ONE, + position_id=_POSITION_TWO, + allocation=Decimal("0.5000"), + suffix=2, + ), + expected_error="Visible allocations for one employment exceed 1.0000.", + employment_id=_EMPLOYMENT_ONE, + ) + + +def test_different_employments_same_position_serializes_on_position_root() -> None: + """A stale seat-capacity snapshot cannot cross the Position conflict boundary.""" + with _isolated_postgres() as database_url: + _seed_fixture(database_url, people=2, positions=1) + _exercise_conflict( + database_url, + first_command=_assignment_command( + assignment_id=UUID("10000000-0000-7000-8002-000000000003"), + employment_id=_EMPLOYMENT_ONE, + person_id=_PERSON_ONE, + position_id=_POSITION_ONE, + allocation=Decimal("0.7500"), + suffix=3, + ), + second_command=_assignment_command( + assignment_id=UUID("10000000-0000-7000-8002-000000000004"), + employment_id=_EMPLOYMENT_TWO, + person_id=_PERSON_TWO, + position_id=_POSITION_ONE, + allocation=Decimal("0.5000"), + suffix=4, + ), + expected_error="Visible allocations for one position exceed 1.0000.", + position_id=_POSITION_ONE, + ) From 015ba7ce98da5a3100194ec9e3389df8b0cedb5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 18:32:56 +0900 Subject: [PATCH 124/269] test(people): repair Assignment concurrency harness review findings --- .../test_postgres_assignment_concurrency_acceptance.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py index b399f1945..83831dcfa 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -25,8 +25,6 @@ from typing import Iterator, Sequence from uuid import UUID, uuid4 -import pytest - from orgmetra_keyverse_adapter import AuthorizationDecision from orgmetra_people_api.mutations import AssignmentMutationCommand, PeopleMutationIntegrityError from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort @@ -339,7 +337,7 @@ def backend_pid(self) -> int: @dataclass(slots=True) class _WriterOutcome: result_id: UUID | None = None - error: BaseException | None = None + error: Exception | None = None def _psql(database_url: str, sql: str) -> str: @@ -595,7 +593,7 @@ def _run_writer( authorization=_authorization(command.assignment_record_id), ) outcome.result_id = result.assignment_record_id - except BaseException as error: + except Exception as error: outcome.error = error From 1ea5cb1dcc887cf5b83fa85f926228e146f09d3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 18:42:25 +0900 Subject: [PATCH 125/269] docs(test): classify synthetic Assignment concurrency evidence honestly --- .../test_postgres_assignment_concurrency_acceptance.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py index 83831dcfa..f358a6bc7 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -1,11 +1,15 @@ """Real PostgreSQL interleavings for governed Assignment conflict domains. -The acceptance deliberately drives ``PostgresPeopleMutationPort`` through a +The integration contract deliberately drives ``PostgresPeopleMutationPort`` through a small libpq DB-API boundary instead of mocking cursors. Each case runs against an isolated PostgreSQL container and holds the first writer immediately before COMMIT, so the second writer must cross the production row-lock boundary. The wait is observed from ``pg_stat_activity``/``pg_blocking_pids``; sleeps never establish the correctness ordering. + +The structural records in this module are deterministic synthetic fixtures. They +prove PostgreSQL serialization mechanics only; they are not real/right-cleared +buyer acceptance data. """ from __future__ import annotations @@ -446,7 +450,7 @@ def _conversion_event(*, audit_id: UUID, conversion_id: UUID, evidence_set_id: U def _seed_fixture(database_url: str, *, people: int, positions: int) -> None: - """Seed right-cleared structural fixtures needed by Assignment acceptance.""" + """Seed deterministic synthetic records for PostgreSQL concurrency integration.""" organization = UUID("10000000-0000-7000-8000-000000000131") job = UUID("10000000-0000-7000-8000-000000000141") person_ids = (_PERSON_ONE, _PERSON_TWO)[:people] @@ -524,7 +528,7 @@ def _seed_fixture(database_url: str, *, people: int, positions: int) -> None: "confirmation_reference, decided_at, recorded_at) " f"VALUES ('{_TENANT}', '{decision_id}', '{candidate_id}', '{job}', '{evidence_set_id}', " "'keyverse_subject:concurrency-fixture', 'talent_acquisition', 'hire', " - "'Right-cleared concurrency acceptance fixture', 'confirmation:concurrency-fixture', " + "'Deterministic concurrency integration fixture', 'confirmation:concurrency-fixture', " "TIMESTAMPTZ '2026-08-17 04:59:00+00', TIMESTAMPTZ '2026-08-17 05:00:00+00');", "SELECT record_audit_outbox_event(" f"'{_TENANT}'::uuid, '{audit_id}'::uuid, '{outbox_id}'::uuid, '{event}', " From 6cf31e8344a39d4490641441c190984533c49360 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 20:09:16 +0900 Subject: [PATCH 126/269] test(people): clean up assignment concurrency writers on failure --- ...tgres_assignment_concurrency_acceptance.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py index f358a6bc7..481af4ef4 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -695,18 +695,21 @@ def _exercise_conflict( daemon=True, ) first.start() - assert barrier.ready.wait(timeout=30), "first Assignment writer did not reach its pre-COMMIT barrier" - assert first_outcome.error is None - second.start() - assert second_factory.connected.wait(timeout=30), "second Assignment writer did not connect" - _assert_database_lock_wait( - database_url, - blocked_pid=second_factory.backend_pid, - blocker_pid=first_factory.backend_pid, - ) - barrier.release.set() - first.join(timeout=30) - second.join(timeout=30) + try: + assert barrier.ready.wait(timeout=30), "first Assignment writer did not reach its pre-COMMIT barrier" + assert first_outcome.error is None + second.start() + assert second_factory.connected.wait(timeout=30), "second Assignment writer did not connect" + _assert_database_lock_wait( + database_url, + blocked_pid=second_factory.backend_pid, + blocker_pid=first_factory.backend_pid, + ) + finally: + barrier.release.set() + first.join() + if second.ident is not None: + second.join() assert not first.is_alive() and not second.is_alive() assert first_outcome.error is None assert first_outcome.result_id == first_command.assignment_record_id From 558afdf482af7e6e69ea0694e9f731365f1300be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 20:33:14 +0900 Subject: [PATCH 127/269] test(people): prove concurrency failure cleanup before teardown --- ..._assignment_concurrency_failure_cleanup.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py new file mode 100644 index 000000000..1edd63b6d --- /dev/null +++ b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py @@ -0,0 +1,75 @@ +"""Failure-path acceptance for Assignment concurrency writer cleanup.""" + +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path +import runpy +from uuid import UUID + + +def _load_acceptance_namespace() -> dict[str, object]: + """Load the sibling PostgreSQL concurrency harness without coupling test order.""" + source = Path(__file__).with_name("test_postgres_assignment_concurrency_acceptance.py") + return runpy.run_path(str(source)) + + +def test_post_lock_assertion_failure_cleans_writer_sessions_before_teardown() -> None: + """A failed assertion after a real lock wait must not leave PostgreSQL writers alive.""" + acceptance = _load_acceptance_namespace() + exercise = acceptance["_exercise_conflict"] + original_lock_assertion = exercise.__globals__["_assert_database_lock_wait"] + + def fail_after_real_lock_observation( + database_url: str, *, blocked_pid: int, blocker_pid: int + ) -> None: + original_lock_assertion( + database_url, + blocked_pid=blocked_pid, + blocker_pid=blocker_pid, + ) + raise AssertionError("forced failure after PostgreSQL lock observation") + + exercise.__globals__["_assert_database_lock_wait"] = fail_after_real_lock_observation + try: + isolated_postgres = acceptance["_isolated_postgres"] + with isolated_postgres() as database_url: + acceptance["_seed_fixture"](database_url, people=1, positions=2) + first_command = acceptance["_assignment_command"]( + assignment_id=UUID("10000000-0000-7000-8003-000000000001"), + employment_id=acceptance["_EMPLOYMENT_ONE"], + person_id=acceptance["_PERSON_ONE"], + position_id=acceptance["_POSITION_ONE"], + allocation=Decimal("0.7500"), + suffix=11, + ) + second_command = acceptance["_assignment_command"]( + assignment_id=UUID("10000000-0000-7000-8003-000000000002"), + employment_id=acceptance["_EMPLOYMENT_ONE"], + person_id=acceptance["_PERSON_ONE"], + position_id=acceptance["_POSITION_TWO"], + allocation=Decimal("0.5000"), + suffix=12, + ) + + try: + exercise( + database_url, + first_command=first_command, + second_command=second_command, + expected_error="Visible allocations for one employment exceed 1.0000.", + employment_id=acceptance["_EMPLOYMENT_ONE"], + ) + except AssertionError as error: + assert str(error) == "forced failure after PostgreSQL lock observation" + else: + raise AssertionError("forced concurrency assertion failure was not propagated") + + active_writers = acceptance["_psql"]( + database_url, + "SELECT count(*) FROM pg_stat_activity " + "WHERE application_name LIKE 'orgmetra-assignment-concurrency-writer-%';", + ) + assert active_writers == "0" + finally: + exercise.__globals__["_assert_database_lock_wait"] = original_lock_assertion From 07c2ff1107f9e39fcb74ec844a5972ca0c7d190a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 20:40:36 +0900 Subject: [PATCH 128/269] test(people): prove client cleanup on concurrency failure --- ..._assignment_concurrency_failure_cleanup.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py index 1edd63b6d..bc41b0fac 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py @@ -15,10 +15,19 @@ def _load_acceptance_namespace() -> dict[str, object]: def test_post_lock_assertion_failure_cleans_writer_sessions_before_teardown() -> None: - """A failed assertion after a real lock wait must not leave PostgreSQL writers alive.""" + """A failed assertion after a real lock wait must close both client and server sessions.""" acceptance = _load_acceptance_namespace() exercise = acceptance["_exercise_conflict"] original_lock_assertion = exercise.__globals__["_assert_database_lock_wait"] + original_connection_factory = exercise.__globals__["_ConnectionFactory"] + created_factories: list[object] = [] + + class _CapturingConnectionFactory(original_connection_factory): + """Retain factories so the failure path can prove client handles were closed.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + created_factories.append(self) def fail_after_real_lock_observation( database_url: str, *, blocked_pid: int, blocker_pid: int @@ -30,6 +39,7 @@ def fail_after_real_lock_observation( ) raise AssertionError("forced failure after PostgreSQL lock observation") + exercise.__globals__["_ConnectionFactory"] = _CapturingConnectionFactory exercise.__globals__["_assert_database_lock_wait"] = fail_after_real_lock_observation try: isolated_postgres = acceptance["_isolated_postgres"] @@ -65,6 +75,12 @@ def fail_after_real_lock_observation( else: raise AssertionError("forced concurrency assertion failure was not propagated") + assert len(created_factories) == 2 + assert all( + connection.closed + for factory in created_factories + for connection in factory.connections + ) active_writers = acceptance["_psql"]( database_url, "SELECT count(*) FROM pg_stat_activity " @@ -72,4 +88,5 @@ def fail_after_real_lock_observation( ) assert active_writers == "0" finally: + exercise.__globals__["_ConnectionFactory"] = original_connection_factory exercise.__globals__["_assert_database_lock_wait"] = original_lock_assertion From fb8eeb9041c0ab838f1c03f0bdfb28500b4397ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:09:19 +0900 Subject: [PATCH 129/269] test(people): require bounded Assignment writer cleanup joins --- ...res_assignment_concurrency_failure_cleanup.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py index bc41b0fac..c70af196c 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py @@ -20,7 +20,9 @@ def test_post_lock_assertion_failure_cleans_writer_sessions_before_teardown() -> exercise = acceptance["_exercise_conflict"] original_lock_assertion = exercise.__globals__["_assert_database_lock_wait"] original_connection_factory = exercise.__globals__["_ConnectionFactory"] + original_threading = exercise.__globals__["threading"] created_factories: list[object] = [] + join_timeouts: list[float | None] = [] class _CapturingConnectionFactory(original_connection_factory): """Retain factories so the failure path can prove client handles were closed.""" @@ -29,6 +31,17 @@ def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) created_factories.append(self) + class _CapturingThread(original_threading.Thread): + """Record whether cleanup joins can hang without an explicit deadline.""" + + def join(self, timeout: float | None = None) -> None: + join_timeouts.append(timeout) + super().join(timeout=timeout) + + class _ThreadingProbe: + Event = original_threading.Event + Thread = _CapturingThread + def fail_after_real_lock_observation( database_url: str, *, blocked_pid: int, blocker_pid: int ) -> None: @@ -41,6 +54,7 @@ def fail_after_real_lock_observation( exercise.__globals__["_ConnectionFactory"] = _CapturingConnectionFactory exercise.__globals__["_assert_database_lock_wait"] = fail_after_real_lock_observation + exercise.__globals__["threading"] = _ThreadingProbe try: isolated_postgres = acceptance["_isolated_postgres"] with isolated_postgres() as database_url: @@ -75,6 +89,7 @@ def fail_after_real_lock_observation( else: raise AssertionError("forced concurrency assertion failure was not propagated") + assert join_timeouts == [30, 30], "concurrency writer cleanup joins must be deadline-bounded" assert len(created_factories) == 2 assert all( connection.closed @@ -90,3 +105,4 @@ def fail_after_real_lock_observation( finally: exercise.__globals__["_ConnectionFactory"] = original_connection_factory exercise.__globals__["_assert_database_lock_wait"] = original_lock_assertion + exercise.__globals__["threading"] = original_threading From 614ee5f2027153223a6cb23773f2b8704674ca2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:11:17 +0900 Subject: [PATCH 130/269] fix(people): bound Assignment concurrency writer joins --- .../tests/test_postgres_assignment_concurrency_acceptance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py index 481af4ef4..1e8a32caf 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -707,9 +707,9 @@ def _exercise_conflict( ) finally: barrier.release.set() - first.join() + first.join(timeout=30) if second.ident is not None: - second.join() + second.join(timeout=30) assert not first.is_alive() and not second.is_alive() assert first_outcome.error is None assert first_outcome.result_id == first_command.assignment_record_id From c1fb77bdd87329755008df94b5161df205a96467 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:41:30 +0900 Subject: [PATCH 131/269] test(people): require backend termination after cleanup deadline --- ..._assignment_concurrency_failure_cleanup.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py index c70af196c..6c41551ad 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py @@ -106,3 +106,134 @@ def fail_after_real_lock_observation( exercise.__globals__["_ConnectionFactory"] = original_connection_factory exercise.__globals__["_assert_database_lock_wait"] = original_lock_assertion exercise.__globals__["threading"] = original_threading + + +def test_expired_cleanup_join_terminates_live_backend_before_returning() -> None: + """A bounded join deadline must not return while an owned PostgreSQL backend is still live.""" + acceptance = _load_acceptance_namespace() + exercise = acceptance["_exercise_conflict"] + original_lock_assertion = exercise.__globals__["_assert_database_lock_wait"] + original_connection_factory = exercise.__globals__["_ConnectionFactory"] + original_connection_type = exercise.__globals__["_LibpqConnection"] + original_connection_exit = original_connection_type.__exit__ + original_psql = exercise.__globals__["_psql"] + original_threading = exercise.__globals__["threading"] + created_factories: list[object] = [] + created_threads: list[object] = [] + termination_sql: list[str] = [] + hold_second_exit = original_threading.Event() + second_waiting_to_close = original_threading.Event() + + class _CapturingConnectionFactory(original_connection_factory): + """Retain writer factories so the timeout path can inspect owned backend identities.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + created_factories.append(self) + + class _ExpireFirstJoinThread(original_threading.Thread): + """Make the first bounded join expire immediately, then allow a real cleanup join.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self._join_calls = 0 + created_threads.append(self) + + def join(self, timeout: float | None = None) -> None: + self._join_calls += 1 + if self._join_calls == 1: + super().join(timeout=0) + return + super().join(timeout=timeout) + + class _ThreadingProbe: + Event = original_threading.Event + Thread = _ExpireFirstJoinThread + + def hold_second_connection_open( + connection: object, exc_type: object, exc: object, traceback: object + ) -> None: + if connection._barrier is None: + second_waiting_to_close.set() + if not hold_second_exit.wait(timeout=30): + raise AssertionError("timed out waiting for cleanup to terminate the blocked writer") + original_connection_exit(connection, exc_type, exc, traceback) + + def observing_psql(database_url: str, sql: str) -> str: + if "pg_terminate_backend" in sql: + termination_sql.append(sql) + hold_second_exit.set() + return original_psql(database_url, sql) + + def fail_after_real_lock_observation( + database_url: str, *, blocked_pid: int, blocker_pid: int + ) -> None: + original_lock_assertion( + database_url, + blocked_pid=blocked_pid, + blocker_pid=blocker_pid, + ) + raise AssertionError("forced failure after PostgreSQL lock observation") + + exercise.__globals__["_ConnectionFactory"] = _CapturingConnectionFactory + exercise.__globals__["_assert_database_lock_wait"] = fail_after_real_lock_observation + exercise.__globals__["_psql"] = observing_psql + exercise.__globals__["threading"] = _ThreadingProbe + original_connection_type.__exit__ = hold_second_connection_open + try: + isolated_postgres = acceptance["_isolated_postgres"] + with isolated_postgres() as database_url: + acceptance["_seed_fixture"](database_url, people=1, positions=2) + first_command = acceptance["_assignment_command"]( + assignment_id=UUID("10000000-0000-7000-8003-000000000003"), + employment_id=acceptance["_EMPLOYMENT_ONE"], + person_id=acceptance["_PERSON_ONE"], + position_id=acceptance["_POSITION_ONE"], + allocation=Decimal("0.7500"), + suffix=13, + ) + second_command = acceptance["_assignment_command"]( + assignment_id=UUID("10000000-0000-7000-8003-000000000004"), + employment_id=acceptance["_EMPLOYMENT_ONE"], + person_id=acceptance["_PERSON_ONE"], + position_id=acceptance["_POSITION_TWO"], + allocation=Decimal("0.5000"), + suffix=14, + ) + + try: + exercise( + database_url, + first_command=first_command, + second_command=second_command, + expected_error="Visible allocations for one employment exceed 1.0000.", + employment_id=acceptance["_EMPLOYMENT_ONE"], + ) + except AssertionError as error: + assert str(error) == "forced failure after PostgreSQL lock observation" + else: + raise AssertionError("forced concurrency assertion failure was not propagated") + + assert second_waiting_to_close.wait(timeout=5), "second writer never reached connection cleanup" + assert termination_sql, "expired cleanup join did not terminate the live PostgreSQL backend" + assert len(created_factories) == 2 + assert all( + connection.closed + for factory in created_factories + for connection in factory.connections + ) + active_writers = original_psql( + database_url, + "SELECT count(*) FROM pg_stat_activity " + "WHERE application_name LIKE 'orgmetra-assignment-concurrency-writer-%';", + ) + assert active_writers == "0" + finally: + hold_second_exit.set() + for thread in created_threads: + original_threading.Thread.join(thread, timeout=5) + original_connection_type.__exit__ = original_connection_exit + exercise.__globals__["_ConnectionFactory"] = original_connection_factory + exercise.__globals__["_assert_database_lock_wait"] = original_lock_assertion + exercise.__globals__["_psql"] = original_psql + exercise.__globals__["threading"] = original_threading From 0d78299f5d49238fc1228f87b138d2bfca6dd367 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:46:45 +0900 Subject: [PATCH 132/269] fix(people): terminate expired concurrency writer backend --- ...tgres_assignment_concurrency_acceptance.py | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py index 1e8a32caf..14feba1f8 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -662,6 +662,32 @@ def _assert_connection_cleanup(database_url: str, factories: Sequence[_Connectio assert active == "0" +def _join_writer_before_teardown( + database_url: str, + *, + thread: threading.Thread, + factory: _ConnectionFactory, +) -> None: + """Bound cleanup, terminate only the owned backend on expiry, and prove thread quiescence.""" + thread.join(timeout=30) + if not thread.is_alive(): + return + if not factory.connections: + raise AssertionError("live Assignment writer has no owned PostgreSQL connection to terminate") + connection = factory.connections[-1] + if not connection.closed: + application_name = factory._application_name.replace("'", "''") + termination = _psql( + database_url, + "SELECT coalesce(bool_and(pg_terminate_backend(pid)), true)::text " + "FROM pg_stat_activity " + f"WHERE pid = {connection.backend_pid} AND application_name = '{application_name}';", + ) + assert termination == "t", "failed to terminate expired Assignment writer backend" + thread.join(timeout=30) + assert not thread.is_alive(), "Assignment writer remained live after backend termination" + + def _exercise_conflict( database_url: str, *, @@ -707,9 +733,9 @@ def _exercise_conflict( ) finally: barrier.release.set() - first.join(timeout=30) + _join_writer_before_teardown(database_url, thread=first, factory=first_factory) if second.ident is not None: - second.join(timeout=30) + _join_writer_before_teardown(database_url, thread=second, factory=second_factory) assert not first.is_alive() and not second.is_alive() assert first_outcome.error is None assert first_outcome.result_id == first_command.assignment_record_id From b71263918cc59b9ca4177abf1770f1c87b619440 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 21:48:06 +0900 Subject: [PATCH 133/269] test(people): observe backend termination before releasing client --- .../test_postgres_assignment_concurrency_failure_cleanup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py index 6c41551ad..64620f1ad 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py @@ -160,10 +160,11 @@ def hold_second_connection_open( original_connection_exit(connection, exc_type, exc, traceback) def observing_psql(database_url: str, sql: str) -> str: + result = original_psql(database_url, sql) if "pg_terminate_backend" in sql: termination_sql.append(sql) hold_second_exit.set() - return original_psql(database_url, sql) + return result def fail_after_real_lock_observation( database_url: str, *, blocked_pid: int, blocker_pid: int From 52257a66f68cf0893f2b351cbc0392b3e52f5bb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 22:30:29 +0900 Subject: [PATCH 134/269] test(people): bind backend termination receipt to owned session --- .../test_postgres_assignment_concurrency_acceptance.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py index 14feba1f8..a0dedcfb0 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -679,11 +679,14 @@ def _join_writer_before_teardown( application_name = factory._application_name.replace("'", "''") termination = _psql( database_url, - "SELECT coalesce(bool_and(pg_terminate_backend(pid)), true)::text " + "SELECT concat_ws('|', count(*), " + "coalesce(bool_and(pg_terminate_backend(pid)), false)::text) " "FROM pg_stat_activity " f"WHERE pid = {connection.backend_pid} AND application_name = '{application_name}';", ) - assert termination == "t", "failed to terminate expired Assignment writer backend" + assert termination == "1|true", ( + "expired Assignment writer backend identity was absent or termination failed" + ) thread.join(timeout=30) assert not thread.is_alive(), "Assignment writer remained live after backend termination" From 818950960c4fe0a4e7b026bd5499fe9cca1002be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 23:11:57 +0900 Subject: [PATCH 135/269] test(people): expire only held concurrency writer join --- ...postgres_assignment_concurrency_failure_cleanup.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py index 64620f1ad..7032fb49a 100644 --- a/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py +++ b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py @@ -131,24 +131,27 @@ def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) created_factories.append(self) - class _ExpireFirstJoinThread(original_threading.Thread): - """Make the first bounded join expire immediately, then allow a real cleanup join.""" + class _ExpireHeldWriterJoinThread(original_threading.Thread): + """Expire only the held writer's first join; let the released writer quiesce normally.""" def __init__(self, *args: object, **kwargs: object) -> None: + writer_kwargs = kwargs.get("kwargs") + factory = writer_kwargs.get("factory") if isinstance(writer_kwargs, dict) else None + self._expire_first_join = getattr(factory, "_barrier", None) is None super().__init__(*args, **kwargs) self._join_calls = 0 created_threads.append(self) def join(self, timeout: float | None = None) -> None: self._join_calls += 1 - if self._join_calls == 1: + if self._expire_first_join and self._join_calls == 1: super().join(timeout=0) return super().join(timeout=timeout) class _ThreadingProbe: Event = original_threading.Event - Thread = _ExpireFirstJoinThread + Thread = _ExpireHeldWriterJoinThread def hold_second_connection_open( connection: object, exc_type: object, exc: object, traceback: object From b8ce423e2ed1306225265bfd2600d9fddc4963b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:05:23 +0900 Subject: [PATCH 136/269] feat(people): add governed employment separation transition --- .../0014_employment_separation_transition.sql | 517 ++++++++++++++++++ 1 file changed, 517 insertions(+) create mode 100644 database/migrations/0014_employment_separation_transition.sql diff --git a/database/migrations/0014_employment_separation_transition.sql b/database/migrations/0014_employment_separation_transition.sql new file mode 100644 index 000000000..83ed39de0 --- /dev/null +++ b/database/migrations/0014_employment_separation_transition.sql @@ -0,0 +1,517 @@ +-- Add the authoritative bitemporal Employment separation transition. +-- +-- Separation is a correction-not-rewrite operation on one Employment aggregate. +-- The current recorded version is closed at a database-owned time, the surviving +-- pre-separation business interval is re-recorded when non-empty, and a terminal +-- version begins on the effective separation date. Assignment history is never +-- rewritten here: any Assignment that would remain effective on/after separation +-- causes the command to fail closed so the Assignment owner can coordinate first. + +BEGIN; + +SET LOCAL search_path = public, pg_catalog; + +ALTER TABLE public.people_mutation_idempotency_record + DROP CONSTRAINT people_mutation_idempotency_route_check; +ALTER TABLE public.people_mutation_idempotency_record + ADD CONSTRAINT people_mutation_idempotency_route_check + CHECK ( + command_route IN ( + 'candidate-worker-conversions', + 'employment-records', + 'employment-separations', + 'position-records', + 'assignment-records' + ) + ); + +ALTER TABLE public.employment_record_version + ADD CONSTRAINT employment_record_version_tenant_identity_unique + UNIQUE (tenant_record_id, employment_record_version_id); + +CREATE TABLE public.employment_separation_record ( + tenant_record_id uuid NOT NULL REFERENCES public.tenant_record(tenant_record_id), + employment_separation_record_id uuid PRIMARY KEY, + employment_record_id uuid NOT NULL, + person_record_id uuid NOT NULL, + prior_employment_record_version_id uuid NOT NULL, + continuation_employment_record_version_id uuid, + separated_employment_record_version_id uuid NOT NULL, + separation_effective_on date NOT NULL, + separation_status_code text NOT NULL, + separation_reason_code text NOT NULL, + evidence_reference text NOT NULL, + evidence_version_code text NOT NULL, + actor_reference text NOT NULL, + purpose_code text NOT NULL, + confirmation_reference text NOT NULL, + command_digest text NOT NULL, + audit_event_record_id uuid NOT NULL, + recorded_at timestamptz NOT NULL, + CONSTRAINT employment_separation_record_id_operational_check + CHECK (public.is_operational_uuid(employment_separation_record_id)), + CONSTRAINT employment_separation_prior_version_operational_check + CHECK (public.is_operational_uuid(prior_employment_record_version_id)), + CONSTRAINT employment_separation_continuation_version_operational_check + CHECK ( + continuation_employment_record_version_id IS NULL + OR public.is_operational_uuid(continuation_employment_record_version_id) + ), + CONSTRAINT employment_separation_terminal_version_operational_check + CHECK (public.is_operational_uuid(separated_employment_record_version_id)), + CONSTRAINT employment_separation_employment_person_tenant_fk + FOREIGN KEY (tenant_record_id, employment_record_id, person_record_id) + REFERENCES public.employment_record( + tenant_record_id, + employment_record_id, + person_record_id + ), + CONSTRAINT employment_separation_prior_version_tenant_fk + FOREIGN KEY (tenant_record_id, prior_employment_record_version_id) + REFERENCES public.employment_record_version( + tenant_record_id, + employment_record_version_id + ), + CONSTRAINT employment_separation_continuation_version_tenant_fk + FOREIGN KEY (tenant_record_id, continuation_employment_record_version_id) + REFERENCES public.employment_record_version( + tenant_record_id, + employment_record_version_id + ), + CONSTRAINT employment_separation_terminal_version_tenant_fk + FOREIGN KEY (tenant_record_id, separated_employment_record_version_id) + REFERENCES public.employment_record_version( + tenant_record_id, + employment_record_version_id + ), + CONSTRAINT employment_separation_audit_event_tenant_fk + FOREIGN KEY (tenant_record_id, audit_event_record_id) + REFERENCES public.audit_event_record(tenant_record_id, audit_event_record_id), + CONSTRAINT employment_separation_status_check + CHECK (separation_status_code = 'terminated'), + CONSTRAINT employment_separation_reason_check + CHECK (separation_reason_code ~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$'), + CONSTRAINT employment_separation_evidence_reference_check + CHECK (evidence_reference ~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$'), + CONSTRAINT employment_separation_evidence_version_check + CHECK (evidence_version_code ~ '^[A-Za-z0-9][A-Za-z0-9._:-]*$'), + CONSTRAINT employment_separation_actor_reference_check + CHECK (actor_reference ~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$'), + CONSTRAINT employment_separation_purpose_check + CHECK (purpose_code = 'workforce_admin'), + CONSTRAINT employment_separation_confirmation_reference_check + CHECK (confirmation_reference ~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$'), + CONSTRAINT employment_separation_command_digest_check + CHECK (command_digest ~ '^[0-9a-f]{64}$'), + CONSTRAINT employment_separation_tenant_identity_unique + UNIQUE (tenant_record_id, employment_separation_record_id), + CONSTRAINT employment_separation_terminal_version_unique + UNIQUE (tenant_record_id, separated_employment_record_version_id), + CONSTRAINT employment_separation_audit_event_unique + UNIQUE (tenant_record_id, audit_event_record_id) +); + +CREATE TRIGGER employment_separation_append_only_guard +BEFORE UPDATE OR DELETE ON public.employment_separation_record +FOR EACH ROW +EXECUTE FUNCTION public.reject_append_only_mutation(); + +CREATE FUNCTION public.reject_employment_separation_truncate() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + RAISE EXCEPTION 'employment separation records cannot be truncated' + USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER employment_separation_truncate_guard +BEFORE TRUNCATE ON public.employment_separation_record +FOR EACH STATEMENT +EXECUTE FUNCTION public.reject_employment_separation_truncate(); + +REVOKE TRUNCATE ON public.employment_separation_record FROM PUBLIC; + +ALTER TABLE public.employment_separation_record ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.employment_separation_record FORCE ROW LEVEL SECURITY; +CREATE POLICY employment_separation_scope_policy ON public.employment_separation_record +USING (tenant_record_id = public.current_tenant_record_id()) +WITH CHECK (tenant_record_id = public.current_tenant_record_id()); + +CREATE FUNCTION public.separate_employment_record_once( + p_tenant_record_id uuid, + p_person_record_id uuid, + p_employment_record_id uuid, + p_expected_employment_record_version_id uuid, + p_separation_effective_on date, + p_separation_reason_code text, + p_evidence_reference text, + p_evidence_version_code text, + p_actor_reference text, + p_purpose_code text, + p_confirmation_reference text, + p_idempotency_key text, + p_audit_event_record_id uuid, + p_outbox_delivery_record_id uuid, + p_canonical_event_json text, + p_event_envelope_digest text +) +RETURNS TABLE ( + employment_record_id uuid, + separated_employment_record_version_id uuid, + recorded_at timestamptz, + replayed boolean +) +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +DECLARE + v_route constant text := 'employment-separations'; + v_current_tenant uuid; + v_command_digest text; + v_replay_record_id uuid; + v_replay_digest text; + v_anchor_person_id uuid; + v_current_count integer; + v_current_status text; + v_current_concurrency text; + v_current_effective_from date; + v_current_effective_to date; + v_recorded_at timestamptz; + v_continuation_version_id uuid; + v_separated_version_id uuid; + v_separation_record_id uuid; + v_idempotency_record_id uuid; + v_event jsonb; +BEGIN + v_current_tenant := public.current_tenant_record_id(); + IF v_current_tenant IS DISTINCT FROM p_tenant_record_id THEN + RAISE EXCEPTION 'employment separation tenant context does not match command tenant' + USING ERRCODE = '42501'; + END IF; + + IF public.is_operational_uuid(p_tenant_record_id) IS NOT TRUE + OR public.is_operational_uuid(p_person_record_id) IS NOT TRUE + OR public.is_operational_uuid(p_employment_record_id) IS NOT TRUE + OR public.is_operational_uuid(p_expected_employment_record_version_id) IS NOT TRUE + OR public.is_operational_uuid(p_audit_event_record_id) IS NOT TRUE + OR public.is_operational_uuid(p_outbox_delivery_record_id) IS NOT TRUE THEN + RAISE EXCEPTION 'employment separation identities must be operational UUIDs' + USING ERRCODE = '22023'; + END IF; + IF p_separation_effective_on IS NULL THEN + RAISE EXCEPTION 'employment separation effective date is required' + USING ERRCODE = '22023'; + END IF; + IF p_separation_reason_code IS NULL + OR p_separation_reason_code !~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' THEN + RAISE EXCEPTION 'employment separation reason code is invalid' + USING ERRCODE = '22023'; + END IF; + IF p_evidence_reference IS NULL + OR p_evidence_reference !~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' THEN + RAISE EXCEPTION 'employment separation evidence reference is invalid' + USING ERRCODE = '22023'; + END IF; + IF p_evidence_version_code IS NULL + OR p_evidence_version_code !~ '^[A-Za-z0-9][A-Za-z0-9._:-]*$' THEN + RAISE EXCEPTION 'employment separation evidence version is invalid' + USING ERRCODE = '22023'; + END IF; + IF p_actor_reference IS NULL + OR p_actor_reference !~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' THEN + RAISE EXCEPTION 'employment separation actor reference is invalid' + USING ERRCODE = '22023'; + END IF; + IF p_purpose_code IS DISTINCT FROM 'workforce_admin' THEN + RAISE EXCEPTION 'employment separation requires workforce_admin purpose' + USING ERRCODE = '42501'; + END IF; + IF p_confirmation_reference IS NULL + OR p_confirmation_reference !~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' THEN + RAISE EXCEPTION 'employment separation confirmation reference is invalid' + USING ERRCODE = '22023'; + END IF; + IF p_idempotency_key IS NULL + OR char_length(p_idempotency_key) NOT BETWEEN 16 AND 200 + OR p_idempotency_key !~ '^[\x21-\x7E]+$' THEN + RAISE EXCEPTION 'employment separation idempotency key is invalid' + USING ERRCODE = '22023'; + END IF; + + v_command_digest := encode( + digest( + convert_to( + jsonb_build_object( + 'actor_reference', p_actor_reference, + 'command_route', v_route, + 'confirmation_reference', p_confirmation_reference, + 'employment_record_id', p_employment_record_id::text, + 'evidence_reference', p_evidence_reference, + 'evidence_version_code', p_evidence_version_code, + 'expected_employment_record_version_id', p_expected_employment_record_version_id::text, + 'person_record_id', p_person_record_id::text, + 'purpose_code', p_purpose_code, + 'separation_effective_on', p_separation_effective_on::text, + 'separation_reason_code', p_separation_reason_code, + 'separation_status_code', 'terminated', + 'tenant_record_id', p_tenant_record_id::text + )::text, + 'UTF8' + ), + 'sha256' + ), + 'hex' + ); + + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended( + p_tenant_record_id::text || E'\x1f' || v_route || E'\x1f' || p_idempotency_key, + 0 + ) + ); + + SELECT replay.created_record_id, replay.command_digest + INTO v_replay_record_id, v_replay_digest + FROM public.people_mutation_idempotency_record AS replay + WHERE replay.tenant_record_id = p_tenant_record_id + AND replay.command_route = v_route + AND replay.idempotency_key = p_idempotency_key; + + IF FOUND THEN + IF v_replay_digest IS DISTINCT FROM v_command_digest THEN + RAISE EXCEPTION 'employment separation idempotency key is bound to a different command' + USING ERRCODE = '23505'; + END IF; + RETURN QUERY + SELECT + separation.employment_record_id, + separation.separated_employment_record_version_id, + separation.recorded_at, + true + FROM public.employment_separation_record AS separation + WHERE separation.tenant_record_id = p_tenant_record_id + AND separation.separated_employment_record_version_id = v_replay_record_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'employment separation replay evidence is missing' + USING ERRCODE = '55000'; + END IF; + RETURN; + END IF; + + SELECT employment.person_record_id + INTO v_anchor_person_id + FROM public.employment_record AS employment + WHERE employment.tenant_record_id = p_tenant_record_id + AND employment.employment_record_id = p_employment_record_id + FOR UPDATE OF employment; + IF NOT FOUND OR v_anchor_person_id IS DISTINCT FROM p_person_record_id THEN + RAISE EXCEPTION 'employment separation target does not match tenant person and employment' + USING ERRCODE = '23503'; + END IF; + + SELECT count(*) + INTO v_current_count + FROM public.employment_record_version AS version + WHERE version.tenant_record_id = p_tenant_record_id + AND version.employment_record_id = p_employment_record_id + AND version.recorded_to IS NULL; + IF v_current_count <> 1 THEN + RAISE EXCEPTION 'employment separation requires exactly one current recorded employment version' + USING ERRCODE = '55000'; + END IF; + + SELECT + version.employment_status_code, + version.employment_concurrency_code, + version.effective_from, + version.effective_to + INTO + v_current_status, + v_current_concurrency, + v_current_effective_from, + v_current_effective_to + FROM public.employment_record_version AS version + WHERE version.tenant_record_id = p_tenant_record_id + AND version.employment_record_id = p_employment_record_id + AND version.employment_record_version_id = p_expected_employment_record_version_id + AND version.recorded_to IS NULL + FOR UPDATE OF version; + IF NOT FOUND THEN + RAISE EXCEPTION 'employment separation expected version is stale or unavailable' + USING ERRCODE = '40001'; + END IF; + IF v_current_status NOT IN ('active', 'leave') THEN + RAISE EXCEPTION 'employment separation requires an active or leave employment version' + USING ERRCODE = '55000'; + END IF; + IF p_separation_effective_on < v_current_effective_from + OR (v_current_effective_to IS NOT NULL AND p_separation_effective_on > v_current_effective_to) THEN + RAISE EXCEPTION 'employment separation date is outside the current business interval' + USING ERRCODE = '22023'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.assignment_record AS assignment + WHERE assignment.tenant_record_id = p_tenant_record_id + AND assignment.employment_record_id = p_employment_record_id + AND assignment.recorded_to IS NULL + AND (assignment.effective_to IS NULL OR assignment.effective_to > p_separation_effective_on) + ) THEN + RAISE EXCEPTION 'employment separation requires assignment coordination before termination' + USING ERRCODE = '55000'; + END IF; + + v_recorded_at := pg_catalog.clock_timestamp(); + v_separated_version_id := gen_random_uuid(); + v_separation_record_id := gen_random_uuid(); + v_idempotency_record_id := gen_random_uuid(); + + UPDATE public.employment_record_version + SET recorded_to = v_recorded_at + WHERE tenant_record_id = p_tenant_record_id + AND employment_record_version_id = p_expected_employment_record_version_id + AND recorded_to IS NULL; + IF NOT FOUND THEN + RAISE EXCEPTION 'employment separation expected version changed during transition' + USING ERRCODE = '40001'; + END IF; + + IF p_separation_effective_on > v_current_effective_from THEN + v_continuation_version_id := gen_random_uuid(); + INSERT INTO public.employment_record_version ( + tenant_record_id, + employment_record_version_id, + employment_record_id, + employment_status_code, + employment_concurrency_code, + effective_from, + effective_to, + recorded_from + ) VALUES ( + p_tenant_record_id, + v_continuation_version_id, + p_employment_record_id, + v_current_status, + v_current_concurrency, + v_current_effective_from, + p_separation_effective_on, + v_recorded_at + ); + END IF; + + INSERT INTO public.employment_record_version ( + tenant_record_id, + employment_record_version_id, + employment_record_id, + employment_status_code, + employment_concurrency_code, + effective_from, + effective_to, + recorded_from + ) VALUES ( + p_tenant_record_id, + v_separated_version_id, + p_employment_record_id, + 'terminated', + v_current_concurrency, + p_separation_effective_on, + NULL, + v_recorded_at + ); + + BEGIN + v_event := p_canonical_event_json::jsonb; + EXCEPTION WHEN others THEN + RAISE EXCEPTION 'employment separation audit event is invalid JSON' + USING ERRCODE = '22023'; + END; + IF v_event ->> 'type' IS DISTINCT FROM 'orgmetra.people.employment_separated' + OR v_event ->> 'subject' IS DISTINCT FROM 'employment_record:' || p_employment_record_id::text + OR v_event ->> 'orgmetratenant' IS DISTINCT FROM p_tenant_record_id::text + OR v_event ->> 'orgmetraactor' IS DISTINCT FROM p_actor_reference + OR v_event ->> 'orgmetrapurpose' IS DISTINCT FROM p_purpose_code + OR v_event ->> 'orgmetrareason' IS DISTINCT FROM p_separation_reason_code + OR v_event ->> 'orgmetraevidence' IS DISTINCT FROM p_evidence_version_code + OR v_event ->> 'orgmetraconfirmation' IS DISTINCT FROM p_confirmation_reference + OR v_event #>> '{data,result_code}' IS DISTINCT FROM 'employment_separated' + OR v_event #>> '{data,high_impact}' IS DISTINCT FROM 'true' THEN + RAISE EXCEPTION 'employment separation audit event does not match command semantics' + USING ERRCODE = '22023'; + END IF; + + PERFORM public.record_audit_outbox_event( + p_tenant_record_id, + p_audit_event_record_id, + p_outbox_delivery_record_id, + p_canonical_event_json, + p_event_envelope_digest, + 'orgmetra_domain_events' + ); + + INSERT INTO public.employment_separation_record ( + tenant_record_id, + employment_separation_record_id, + employment_record_id, + person_record_id, + prior_employment_record_version_id, + continuation_employment_record_version_id, + separated_employment_record_version_id, + separation_effective_on, + separation_status_code, + separation_reason_code, + evidence_reference, + evidence_version_code, + actor_reference, + purpose_code, + confirmation_reference, + command_digest, + audit_event_record_id, + recorded_at + ) VALUES ( + p_tenant_record_id, + v_separation_record_id, + p_employment_record_id, + p_person_record_id, + p_expected_employment_record_version_id, + v_continuation_version_id, + v_separated_version_id, + p_separation_effective_on, + 'terminated', + p_separation_reason_code, + p_evidence_reference, + p_evidence_version_code, + p_actor_reference, + p_purpose_code, + p_confirmation_reference, + v_command_digest, + p_audit_event_record_id, + v_recorded_at + ); + + INSERT INTO public.people_mutation_idempotency_record ( + tenant_record_id, + people_mutation_idempotency_record_id, + command_route, + idempotency_key, + command_digest, + created_record_id, + recorded_from + ) VALUES ( + p_tenant_record_id, + v_idempotency_record_id, + v_route, + p_idempotency_key, + v_command_digest, + v_separated_version_id, + v_recorded_at + ); + + RETURN QUERY SELECT p_employment_record_id, v_separated_version_id, v_recorded_at, false; +END; +$$; + +COMMIT; From 2a3b3810746594b262b8433210cb7e93e24effaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:07:44 +0900 Subject: [PATCH 137/269] fix(people): bind separation audit time to database clock --- .../0014_employment_separation_transition.sql | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/database/migrations/0014_employment_separation_transition.sql b/database/migrations/0014_employment_separation_transition.sql index 83ed39de0..0622f0a13 100644 --- a/database/migrations/0014_employment_separation_transition.sql +++ b/database/migrations/0014_employment_separation_transition.sql @@ -154,9 +154,7 @@ CREATE FUNCTION public.separate_employment_record_once( p_confirmation_reference text, p_idempotency_key text, p_audit_event_record_id uuid, - p_outbox_delivery_record_id uuid, - p_canonical_event_json text, - p_event_envelope_digest text + p_outbox_delivery_record_id uuid ) RETURNS TABLE ( employment_record_id uuid, @@ -184,7 +182,9 @@ DECLARE v_separated_version_id uuid; v_separation_record_id uuid; v_idempotency_record_id uuid; - v_event jsonb; + v_event_time text; + v_canonical_event_json text; + v_event_envelope_digest text; BEGIN v_current_tenant := public.current_tenant_record_id(); IF v_current_tenant IS DISTINCT FROM p_tenant_record_id THEN @@ -423,32 +423,36 @@ BEGIN v_recorded_at ); - BEGIN - v_event := p_canonical_event_json::jsonb; - EXCEPTION WHEN others THEN - RAISE EXCEPTION 'employment separation audit event is invalid JSON' - USING ERRCODE = '22023'; - END; - IF v_event ->> 'type' IS DISTINCT FROM 'orgmetra.people.employment_separated' - OR v_event ->> 'subject' IS DISTINCT FROM 'employment_record:' || p_employment_record_id::text - OR v_event ->> 'orgmetratenant' IS DISTINCT FROM p_tenant_record_id::text - OR v_event ->> 'orgmetraactor' IS DISTINCT FROM p_actor_reference - OR v_event ->> 'orgmetrapurpose' IS DISTINCT FROM p_purpose_code - OR v_event ->> 'orgmetrareason' IS DISTINCT FROM p_separation_reason_code - OR v_event ->> 'orgmetraevidence' IS DISTINCT FROM p_evidence_version_code - OR v_event ->> 'orgmetraconfirmation' IS DISTINCT FROM p_confirmation_reference - OR v_event #>> '{data,result_code}' IS DISTINCT FROM 'employment_separated' - OR v_event #>> '{data,high_impact}' IS DISTINCT FROM 'true' THEN - RAISE EXCEPTION 'employment separation audit event does not match command semantics' - USING ERRCODE = '22023'; - END IF; + v_event_time := pg_catalog.to_char( + v_recorded_at AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"' + ); + v_canonical_event_json := + '{"data":{"high_impact":true,"result_code":"employment_separated"},' + || '"datacontenttype":"application/json",' + || '"id":' || pg_catalog.to_json(p_audit_event_record_id::text)::text || ',' + || '"orgmetraactor":' || pg_catalog.to_json(p_actor_reference)::text || ',' + || '"orgmetraconfirmation":' || pg_catalog.to_json(p_confirmation_reference)::text || ',' + || '"orgmetraevidence":' || pg_catalog.to_json(p_evidence_version_code)::text || ',' + || '"orgmetrapurpose":' || pg_catalog.to_json(p_purpose_code)::text || ',' + || '"orgmetrareason":' || pg_catalog.to_json(p_separation_reason_code)::text || ',' + || '"orgmetratenant":' || pg_catalog.to_json(p_tenant_record_id::text)::text || ',' + || '"source":"urn:orgmetra:people_api",' + || '"specversion":"1.0",' + || '"subject":' || pg_catalog.to_json('employment_record:' || p_employment_record_id::text)::text || ',' + || '"time":' || pg_catalog.to_json(v_event_time)::text || ',' + || '"type":"orgmetra.people.employment_separated"}'; + v_event_envelope_digest := encode( + digest(convert_to(v_canonical_event_json, 'UTF8'), 'sha256'), + 'hex' + ); PERFORM public.record_audit_outbox_event( p_tenant_record_id, p_audit_event_record_id, p_outbox_delivery_record_id, - p_canonical_event_json, - p_event_envelope_digest, + v_canonical_event_json, + v_event_envelope_digest, 'orgmetra_domain_events' ); From 3cc9322a71c424107e5353557b12112716396bee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:10:34 +0900 Subject: [PATCH 138/269] test(people): prove governed employment separation history --- tests/test_bitemporal_postgres.sh | 185 +++++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 3 deletions(-) diff --git a/tests/test_bitemporal_postgres.sh b/tests/test_bitemporal_postgres.sh index 941c79608..8dcd971be 100644 --- a/tests/test_bitemporal_postgres.sh +++ b/tests/test_bitemporal_postgres.sh @@ -4,8 +4,23 @@ set -euo pipefail : "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" TENANT_ID='10000000-0000-7000-8000-000000000001' -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0001_foundation_schema.sql -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0002_sealed_evidence_digest.sql +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0003_audit_outbox_persistence.sql \ + database/migrations/0004_outbox_delivery_claim.sql \ + database/migrations/0005_outbox_delivery_finalization.sql \ + database/migrations/0006_outbox_delivery_dead_letter.sql \ + database/migrations/0007_outbox_retry_exhaustion.sql \ + database/migrations/0008_audit_outbox_review_hardening.sql \ + database/migrations/0009_candidate_worker_conversion_governance.sql \ + database/migrations/0010_validity_study_case_integrity.sql \ + database/migrations/0011_criterion_observation_scope.sql \ + database/migrations/0012_people_mutation_idempotency.sql \ + database/migrations/0013_job_analysis_snapshot.sql \ + database/migrations/0014_employment_separation_transition.sql; do + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' INSERT INTO tenant_record (tenant_record_id, tenant_reference) @@ -227,4 +242,168 @@ if [[ "${overlap_output}" != *"employment_record_bitemporal_exclusion"* ]]; then exit 1 fi -echo "PostgreSQL bitemporal concurrency contract passed" +separation_result="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtF '|' <<'SQL' +BEGIN; +SET LOCAL orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; +SELECT + employment_record_id, + separated_employment_record_version_id, + recorded_at, + replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000002'::uuid, + '00000000-0000-7000-8000-000000000021'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:sep-2026-001', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-17', + 'employment-separation-key-17', + '00000000-0000-4000-8000-000000000230'::uuid, + '00000000-0000-4000-8000-000000000231'::uuid +); +COMMIT; +SQL +)" +if [[ "${separation_result}" != 00000000-0000-7000-8000-000000000002\|*\|*\|f ]]; then + echo "employment separation did not return the first authoritative result: ${separation_result}" >&2 + exit 1 +fi +separated_version_id="$(printf '%s' "${separation_result}" | cut -d'|' -f2)" +separation_recorded_at="$(printf '%s' "${separation_result}" | cut -d'|' -f3)" + +separation_shape="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtF '|' < DATE '2026-05-31'), + (SELECT employment_status_code FROM employment_record_version + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '00000000-0000-7000-8000-000000000002'::uuid + AND recorded_to IS NULL + AND daterange(effective_from, effective_to, '[)') @> DATE '2026-06-01'), + (SELECT count(*) FROM employment_separation_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '00000000-0000-7000-8000-000000000002'::uuid), + (SELECT count(*) FROM audit_event_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND audit_event_record_id = '00000000-0000-4000-8000-000000000230'::uuid + AND (canonical_event_json::jsonb ->> 'time')::timestamptz = '${separation_recorded_at}'::timestamptz + AND canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated' + AND canonical_event_json::jsonb #>> '{data,result_code}' = 'employment_separated'), + (SELECT count(*) FROM people_mutation_idempotency_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND command_route = 'employment-separations' + AND created_record_id = '${separated_version_id}'::uuid) +); +SQL +)" +if [[ "${separation_shape}" != "2|active|terminated|1|1|1" ]]; then + echo "employment separation did not preserve one current bitemporal history and evidence set: ${separation_shape}" >&2 + exit 1 +fi + +historic_status="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT employment_status_code +FROM employment_record_version +WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '00000000-0000-7000-8000-000000000002'::uuid + AND daterange(effective_from, effective_to, '[)') @> DATE '2026-07-01' + AND tstzrange(recorded_from, recorded_to, '[)') @> TIMESTAMPTZ '2026-02-01 00:00:00+00'; +")" +if [[ "${historic_status}" != "active" ]]; then + echo "employment separation destroyed pre-separation knowledge history: ${historic_status}" >&2 + exit 1 +fi + +replay_result="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtF '|' <<'SQL' +BEGIN; +SET LOCAL orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; +SELECT + employment_record_id, + separated_employment_record_version_id, + recorded_at, + replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000002'::uuid, + '00000000-0000-7000-8000-000000000021'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:sep-2026-001', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-17', + 'employment-separation-key-17', + '00000000-0000-4000-8000-000000000232'::uuid, + '00000000-0000-4000-8000-000000000233'::uuid +); +COMMIT; +SQL +)" +if [[ "${replay_result}" != "00000000-0000-7000-8000-000000000002|${separated_version_id}|${separation_recorded_at}|t" ]]; then + echo "matching employment separation retry did not replay the first committed truth: ${replay_result}" >&2 + exit 1 +fi + +set +e +semantic_conflict_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +BEGIN; +SET LOCAL orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; +SELECT * FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000002'::uuid, + '00000000-0000-7000-8000-000000000021'::uuid, + DATE '2026-06-02', + 'voluntary_resignation', + 'separation_packet:sep-2026-001', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-17', + 'employment-separation-key-17', + '00000000-0000-4000-8000-000000000234'::uuid, + '00000000-0000-4000-8000-000000000235'::uuid +); +COMMIT; +SQL +} 2>&1)" +semantic_conflict_status=$? +set -e +if [[ ${semantic_conflict_status} -eq 0 ]]; then + echo "changed employment separation command reused an idempotency key" >&2 + exit 1 +fi +if [[ "${semantic_conflict_output}" != *"idempotency key is bound to a different command"* ]]; then + echo "changed employment separation command failed for an unexpected reason: ${semantic_conflict_output}" >&2 + exit 1 +fi + +final_counts="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SET orgmetra.tenant_record_id = '${TENANT_ID}'; +SELECT concat_ws(',', + (SELECT count(*) FROM employment_separation_record), + (SELECT count(*) FROM audit_event_record WHERE canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated'), + (SELECT count(*) FROM people_mutation_idempotency_record WHERE command_route = 'employment-separations') +); +")" +if [[ "${final_counts}" != "1,1,1" ]]; then + echo "employment separation retry/conflict changed durable truth: ${final_counts}" >&2 + exit 1 +fi + +echo "PostgreSQL bitemporal concurrency and employment separation contract passed" From 3578026582c07c77f87170ec21cbfd74d7d6f5c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:13:19 +0900 Subject: [PATCH 139/269] test(people): keep Foundation contract ownership unchanged --- tests/test_bitemporal_postgres.sh | 185 +----------------------------- 1 file changed, 3 insertions(+), 182 deletions(-) diff --git a/tests/test_bitemporal_postgres.sh b/tests/test_bitemporal_postgres.sh index 8dcd971be..941c79608 100644 --- a/tests/test_bitemporal_postgres.sh +++ b/tests/test_bitemporal_postgres.sh @@ -4,23 +4,8 @@ set -euo pipefail : "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" TENANT_ID='10000000-0000-7000-8000-000000000001' -for migration in \ - database/migrations/0001_foundation_schema.sql \ - database/migrations/0002_sealed_evidence_digest.sql \ - database/migrations/0003_audit_outbox_persistence.sql \ - database/migrations/0004_outbox_delivery_claim.sql \ - database/migrations/0005_outbox_delivery_finalization.sql \ - database/migrations/0006_outbox_delivery_dead_letter.sql \ - database/migrations/0007_outbox_retry_exhaustion.sql \ - database/migrations/0008_audit_outbox_review_hardening.sql \ - database/migrations/0009_candidate_worker_conversion_governance.sql \ - database/migrations/0010_validity_study_case_integrity.sql \ - database/migrations/0011_criterion_observation_scope.sql \ - database/migrations/0012_people_mutation_idempotency.sql \ - database/migrations/0013_job_analysis_snapshot.sql \ - database/migrations/0014_employment_separation_transition.sql; do - psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" -done +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0001_foundation_schema.sql +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0002_sealed_evidence_digest.sql psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' INSERT INTO tenant_record (tenant_record_id, tenant_reference) @@ -242,168 +227,4 @@ if [[ "${overlap_output}" != *"employment_record_bitemporal_exclusion"* ]]; then exit 1 fi -separation_result="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtF '|' <<'SQL' -BEGIN; -SET LOCAL orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; -SELECT - employment_record_id, - separated_employment_record_version_id, - recorded_at, - replayed -FROM public.separate_employment_record_once( - '10000000-0000-7000-8000-000000000001'::uuid, - '00000000-0000-7000-8000-000000000001'::uuid, - '00000000-0000-7000-8000-000000000002'::uuid, - '00000000-0000-7000-8000-000000000021'::uuid, - DATE '2026-06-01', - 'voluntary_resignation', - 'separation_packet:sep-2026-001', - 'v1', - 'keyverse_subject:operator-17', - 'workforce_admin', - 'human_confirmation:separation-17', - 'employment-separation-key-17', - '00000000-0000-4000-8000-000000000230'::uuid, - '00000000-0000-4000-8000-000000000231'::uuid -); -COMMIT; -SQL -)" -if [[ "${separation_result}" != 00000000-0000-7000-8000-000000000002\|*\|*\|f ]]; then - echo "employment separation did not return the first authoritative result: ${separation_result}" >&2 - exit 1 -fi -separated_version_id="$(printf '%s' "${separation_result}" | cut -d'|' -f2)" -separation_recorded_at="$(printf '%s' "${separation_result}" | cut -d'|' -f3)" - -separation_shape="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtF '|' < DATE '2026-05-31'), - (SELECT employment_status_code FROM employment_record_version - WHERE tenant_record_id = '${TENANT_ID}'::uuid - AND employment_record_id = '00000000-0000-7000-8000-000000000002'::uuid - AND recorded_to IS NULL - AND daterange(effective_from, effective_to, '[)') @> DATE '2026-06-01'), - (SELECT count(*) FROM employment_separation_record - WHERE tenant_record_id = '${TENANT_ID}'::uuid - AND employment_record_id = '00000000-0000-7000-8000-000000000002'::uuid), - (SELECT count(*) FROM audit_event_record - WHERE tenant_record_id = '${TENANT_ID}'::uuid - AND audit_event_record_id = '00000000-0000-4000-8000-000000000230'::uuid - AND (canonical_event_json::jsonb ->> 'time')::timestamptz = '${separation_recorded_at}'::timestamptz - AND canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated' - AND canonical_event_json::jsonb #>> '{data,result_code}' = 'employment_separated'), - (SELECT count(*) FROM people_mutation_idempotency_record - WHERE tenant_record_id = '${TENANT_ID}'::uuid - AND command_route = 'employment-separations' - AND created_record_id = '${separated_version_id}'::uuid) -); -SQL -)" -if [[ "${separation_shape}" != "2|active|terminated|1|1|1" ]]; then - echo "employment separation did not preserve one current bitemporal history and evidence set: ${separation_shape}" >&2 - exit 1 -fi - -historic_status="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " -SELECT employment_status_code -FROM employment_record_version -WHERE tenant_record_id = '${TENANT_ID}'::uuid - AND employment_record_id = '00000000-0000-7000-8000-000000000002'::uuid - AND daterange(effective_from, effective_to, '[)') @> DATE '2026-07-01' - AND tstzrange(recorded_from, recorded_to, '[)') @> TIMESTAMPTZ '2026-02-01 00:00:00+00'; -")" -if [[ "${historic_status}" != "active" ]]; then - echo "employment separation destroyed pre-separation knowledge history: ${historic_status}" >&2 - exit 1 -fi - -replay_result="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtF '|' <<'SQL' -BEGIN; -SET LOCAL orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; -SELECT - employment_record_id, - separated_employment_record_version_id, - recorded_at, - replayed -FROM public.separate_employment_record_once( - '10000000-0000-7000-8000-000000000001'::uuid, - '00000000-0000-7000-8000-000000000001'::uuid, - '00000000-0000-7000-8000-000000000002'::uuid, - '00000000-0000-7000-8000-000000000021'::uuid, - DATE '2026-06-01', - 'voluntary_resignation', - 'separation_packet:sep-2026-001', - 'v1', - 'keyverse_subject:operator-17', - 'workforce_admin', - 'human_confirmation:separation-17', - 'employment-separation-key-17', - '00000000-0000-4000-8000-000000000232'::uuid, - '00000000-0000-4000-8000-000000000233'::uuid -); -COMMIT; -SQL -)" -if [[ "${replay_result}" != "00000000-0000-7000-8000-000000000002|${separated_version_id}|${separation_recorded_at}|t" ]]; then - echo "matching employment separation retry did not replay the first committed truth: ${replay_result}" >&2 - exit 1 -fi - -set +e -semantic_conflict_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' -BEGIN; -SET LOCAL orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; -SELECT * FROM public.separate_employment_record_once( - '10000000-0000-7000-8000-000000000001'::uuid, - '00000000-0000-7000-8000-000000000001'::uuid, - '00000000-0000-7000-8000-000000000002'::uuid, - '00000000-0000-7000-8000-000000000021'::uuid, - DATE '2026-06-02', - 'voluntary_resignation', - 'separation_packet:sep-2026-001', - 'v1', - 'keyverse_subject:operator-17', - 'workforce_admin', - 'human_confirmation:separation-17', - 'employment-separation-key-17', - '00000000-0000-4000-8000-000000000234'::uuid, - '00000000-0000-4000-8000-000000000235'::uuid -); -COMMIT; -SQL -} 2>&1)" -semantic_conflict_status=$? -set -e -if [[ ${semantic_conflict_status} -eq 0 ]]; then - echo "changed employment separation command reused an idempotency key" >&2 - exit 1 -fi -if [[ "${semantic_conflict_output}" != *"idempotency key is bound to a different command"* ]]; then - echo "changed employment separation command failed for an unexpected reason: ${semantic_conflict_output}" >&2 - exit 1 -fi - -final_counts="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " -SET orgmetra.tenant_record_id = '${TENANT_ID}'; -SELECT concat_ws(',', - (SELECT count(*) FROM employment_separation_record), - (SELECT count(*) FROM audit_event_record WHERE canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated'), - (SELECT count(*) FROM people_mutation_idempotency_record WHERE command_route = 'employment-separations') -); -")" -if [[ "${final_counts}" != "1,1,1" ]]; then - echo "employment separation retry/conflict changed durable truth: ${final_counts}" >&2 - exit 1 -fi - -echo "PostgreSQL bitemporal concurrency and employment separation contract passed" +echo "PostgreSQL bitemporal concurrency contract passed" From ba4dc19fbba376b741a7f86ea218244e4d9e6651 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:14:57 +0900 Subject: [PATCH 140/269] fix(people): separate the exact current Employment interval --- .../0014_employment_separation_transition.sql | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/database/migrations/0014_employment_separation_transition.sql b/database/migrations/0014_employment_separation_transition.sql index 0622f0a13..c0e0c0555 100644 --- a/database/migrations/0014_employment_separation_transition.sql +++ b/database/migrations/0014_employment_separation_transition.sql @@ -172,7 +172,6 @@ DECLARE v_replay_record_id uuid; v_replay_digest text; v_anchor_person_id uuid; - v_current_count integer; v_current_status text; v_current_concurrency text; v_current_effective_from date; @@ -312,17 +311,6 @@ BEGIN USING ERRCODE = '23503'; END IF; - SELECT count(*) - INTO v_current_count - FROM public.employment_record_version AS version - WHERE version.tenant_record_id = p_tenant_record_id - AND version.employment_record_id = p_employment_record_id - AND version.recorded_to IS NULL; - IF v_current_count <> 1 THEN - RAISE EXCEPTION 'employment separation requires exactly one current recorded employment version' - USING ERRCODE = '55000'; - END IF; - SELECT version.employment_status_code, version.employment_concurrency_code, @@ -348,11 +336,25 @@ BEGIN USING ERRCODE = '55000'; END IF; IF p_separation_effective_on < v_current_effective_from - OR (v_current_effective_to IS NOT NULL AND p_separation_effective_on > v_current_effective_to) THEN - RAISE EXCEPTION 'employment separation date is outside the current business interval' + OR (v_current_effective_to IS NOT NULL AND p_separation_effective_on >= v_current_effective_to) THEN + RAISE EXCEPTION 'employment separation date is outside the expected current business interval' USING ERRCODE = '22023'; END IF; + IF EXISTS ( + SELECT 1 + FROM public.employment_record_version AS other_version + WHERE other_version.tenant_record_id = p_tenant_record_id + AND other_version.employment_record_id = p_employment_record_id + AND other_version.employment_record_version_id <> p_expected_employment_record_version_id + AND other_version.recorded_to IS NULL + AND daterange(other_version.effective_from, other_version.effective_to, '[)') + && daterange(p_separation_effective_on, NULL, '[)') + ) THEN + RAISE EXCEPTION 'employment separation requires future Employment version coordination' + USING ERRCODE = '55000'; + END IF; + IF EXISTS ( SELECT 1 FROM public.assignment_record AS assignment From 1e17a594cf01789ab7f5e994c7aa5d956aafa782 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:15:34 +0900 Subject: [PATCH 141/269] test(people): add Employment separation PostgreSQL contract --- tests/test_employment_separation_postgres.sh | 135 +++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/test_employment_separation_postgres.sh diff --git a/tests/test_employment_separation_postgres.sh b/tests/test_employment_separation_postgres.sh new file mode 100644 index 000000000..e83cc79e5 --- /dev/null +++ b/tests/test_employment_separation_postgres.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" +TENANT_ID='10000000-0000-7000-8000-000000000001' +FOREIGN_TENANT_ID='20000000-0000-7000-8000-000000000001' +PERSON_ID='00000000-0000-7000-8000-000000000001' + +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0003_audit_outbox_persistence.sql \ + database/migrations/0004_outbox_delivery_claim.sql \ + database/migrations/0005_outbox_delivery_finalization.sql \ + database/migrations/0006_outbox_delivery_dead_letter.sql \ + database/migrations/0007_outbox_retry_exhaustion.sql \ + database/migrations/0008_audit_outbox_review_hardening.sql \ + database/migrations/0009_candidate_worker_conversion_governance.sql \ + database/migrations/0010_validity_study_case_integrity.sql \ + database/migrations/0011_criterion_observation_scope.sql \ + database/migrations/0012_people_mutation_idempotency.sql \ + database/migrations/0013_job_analysis_snapshot.sql \ + database/migrations/0014_employment_separation_transition.sql; do + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO tenant_record (tenant_record_id, tenant_reference) +VALUES + ('10000000-0000-7000-8000-000000000001', 'tenant_alpha'), + ('20000000-0000-7000-8000-000000000001', 'tenant_beta'); + +INSERT INTO person_record (tenant_record_id, person_record_id, recorded_from) +VALUES ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000001', + TIMESTAMPTZ '2026-01-02 00:00:00+00' +); + +INSERT INTO employment_record ( + tenant_record_id, employment_record_id, person_record_id, recorded_from +) VALUES + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000101', + '00000000-0000-7000-8000-000000000001', + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ), + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000102', + '00000000-0000-7000-8000-000000000001', + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ), + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000103', + '00000000-0000-7000-8000-000000000001', + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ), + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000104', + '00000000-0000-7000-8000-000000000001', + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ); + +INSERT INTO employment_record_version ( + tenant_record_id, + employment_record_version_id, + employment_record_id, + employment_status_code, + employment_concurrency_code, + effective_from, + effective_to, + recorded_from +) VALUES + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000201', + '00000000-0000-7000-8000-000000000101', + 'active', 'exclusive', DATE '2026-01-01', NULL, + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ), + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000202', + '00000000-0000-7000-8000-000000000102', + 'active', 'concurrent', DATE '2026-01-01', NULL, + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ), + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000203', + '00000000-0000-7000-8000-000000000103', + 'active', 'concurrent', DATE '2026-01-01', DATE '2026-09-01', + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ), + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000204', + '00000000-0000-7000-8000-000000000103', + 'leave', 'concurrent', DATE '2026-09-01', NULL, + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ), + ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000205', + '00000000-0000-7000-8000-000000000104', + 'active', 'concurrent', DATE '2026-01-01', NULL, + TIMESTAMPTZ '2026-01-02 00:00:00+00' + ); + +INSERT INTO organization_unit (tenant_record_id, organization_unit_id, recorded_from) +VALUES ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000301', + TIMESTAMPTZ '2026-01-02 00:00:00+00' +); +INSERT INTO job_profile (tenant_record_id, job_profile_id, recorded_from) +VALUES ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000302', + TIMESTAMPTZ '2026-01-02 00:00:00+00' +); +INSERT INTO position_record ( + tenant_record_id, position_record_id, organization_unit_id, job_profile_id, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000303', + '00000000-0000-7000-8000-000000000303', + '00000000-0000-7000-8000-000000000301', + '00000000-0000-7000-8000-000000000302', + TIMESTAMPTZ '2026-01-02 00:00:00+00' +); +SQL From efa89a6641c33cc76ce957ce8d15ad7a8919d658 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:16:59 +0900 Subject: [PATCH 142/269] test(people): complete Employment separation acceptance --- tests/test_employment_separation_postgres.sh | 403 ++++++++++++++++++- 1 file changed, 402 insertions(+), 1 deletion(-) diff --git a/tests/test_employment_separation_postgres.sh b/tests/test_employment_separation_postgres.sh index e83cc79e5..cc1f8dc8e 100644 --- a/tests/test_employment_separation_postgres.sh +++ b/tests/test_employment_separation_postgres.sh @@ -24,6 +24,13 @@ for migration in \ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" done +tenant_psql() { + PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" command psql "$@" +} +foreign_tenant_psql() { + PGOPTIONS="-c orgmetra.tenant_record_id=${FOREIGN_TENANT_ID}" command psql "$@" +} + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' INSERT INTO tenant_record (tenant_record_id, tenant_reference) VALUES @@ -126,10 +133,404 @@ VALUES ( INSERT INTO position_record ( tenant_record_id, position_record_id, organization_unit_id, job_profile_id, recorded_from ) VALUES ( - '10000000-0000-7000-8000-000000000303', + '10000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000303', '00000000-0000-7000-8000-000000000301', '00000000-0000-7000-8000-000000000302', TIMESTAMPTZ '2026-01-02 00:00:00+00' ); +INSERT INTO assignment_record ( + tenant_record_id, + assignment_record_id, + employment_record_id, + person_record_id, + position_record_id, + allocation_ratio, + effective_from, + effective_to, + recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000401', + '00000000-0000-7000-8000-000000000104', + '00000000-0000-7000-8000-000000000001', + '00000000-0000-7000-8000-000000000303', + 1.0000, + DATE '2026-01-01', + NULL, + TIMESTAMPTZ '2026-01-02 00:00:00+00' +); +SQL + +first_result="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <<'SQL' +SELECT employment_record_id, separated_employment_record_version_id, recorded_at, replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000101'::uuid, + '00000000-0000-7000-8000-000000000201'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:sep-2026-001', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-17', + 'employment-separation-key-17', + '00000000-0000-4000-8000-000000000501'::uuid, + '00000000-0000-4000-8000-000000000601'::uuid +); +SQL +)" +first_employment_id="$(printf '%s' "${first_result}" | cut -d'|' -f1)" +first_version_id="$(printf '%s' "${first_result}" | cut -d'|' -f2)" +first_recorded_at="$(printf '%s' "${first_result}" | cut -d'|' -f3)" +first_replayed="$(printf '%s' "${first_result}" | cut -d'|' -f4)" +if [[ "${first_employment_id}" != "00000000-0000-7000-8000-000000000101" || -z "${first_version_id}" || -z "${first_recorded_at}" || "${first_replayed}" != "f" ]]; then + echo "first employment separation result is invalid: ${first_result}" >&2 + exit 1 +fi + +shape="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' < DATE '2026-05-31'), + (SELECT employment_status_code FROM public.employment_record_version + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '00000000-0000-7000-8000-000000000101'::uuid + AND recorded_to IS NULL + AND daterange(effective_from, effective_to, '[)') @> DATE '2026-06-01'), + (SELECT count(*) FROM public.employment_separation_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '00000000-0000-7000-8000-000000000101'::uuid + AND separated_employment_record_version_id = '${first_version_id}'::uuid + AND recorded_at = '${first_recorded_at}'::timestamptz), + (SELECT count(*) FROM public.audit_event_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND audit_event_record_id = '00000000-0000-4000-8000-000000000501'::uuid + AND (canonical_event_json::jsonb ->> 'time')::timestamptz = '${first_recorded_at}'::timestamptz + AND canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated' + AND canonical_event_json::jsonb ->> 'subject' = 'employment_record:00000000-0000-7000-8000-000000000101' + AND canonical_event_json::jsonb #>> '{data,result_code}' = 'employment_separated'), + (SELECT count(*) FROM public.people_mutation_idempotency_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND command_route = 'employment-separations' + AND created_record_id = '${first_version_id}'::uuid) +); +SQL +)" +if [[ "${shape}" != "2|active|terminated|1|1|1" ]]; then + echo "employment separation durable shape is invalid: ${shape}" >&2 + exit 1 +fi + +historic_status="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT employment_status_code +FROM public.employment_record_version +WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '00000000-0000-7000-8000-000000000101'::uuid + AND daterange(effective_from, effective_to, '[)') @> DATE '2026-07-01' + AND tstzrange(recorded_from, recorded_to, '[)') @> TIMESTAMPTZ '2026-02-01 00:00:00+00'; +")" +if [[ "${historic_status}" != "active" ]]; then + echo "separation destroyed earlier knowledge history: ${historic_status}" >&2 + exit 1 +fi + +replay_result="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <<'SQL' +SELECT employment_record_id, separated_employment_record_version_id, recorded_at, replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000101'::uuid, + '00000000-0000-7000-8000-000000000201'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:sep-2026-001', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-17', + 'employment-separation-key-17', + '00000000-0000-4000-8000-000000000502'::uuid, + '00000000-0000-4000-8000-000000000602'::uuid +); +SQL +)" +if [[ "${replay_result}" != "${first_employment_id}|${first_version_id}|${first_recorded_at}|t" ]]; then + echo "matching separation retry did not replay first committed result: ${replay_result}" >&2 + exit 1 +fi + +set +e +semantic_conflict_output="$({ tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT * FROM public.separate_employment_record_once( + '${TENANT_ID}'::uuid, + '${PERSON_ID}'::uuid, + '00000000-0000-7000-8000-000000000101'::uuid, + '00000000-0000-7000-8000-000000000201'::uuid, + DATE '2026-06-02', + 'voluntary_resignation', + 'separation_packet:sep-2026-001', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-17', + 'employment-separation-key-17', + '00000000-0000-4000-8000-000000000503'::uuid, + '00000000-0000-4000-8000-000000000603'::uuid +);"; } 2>&1)" +semantic_conflict_status=$? +set -e +if [[ ${semantic_conflict_status} -eq 0 || "${semantic_conflict_output}" != *"idempotency key is bound to a different command"* ]]; then + echo "same-key semantic conflict was not rejected correctly: ${semantic_conflict_output}" >&2 + exit 1 +fi + +set +e +stale_output="$({ tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT * FROM public.separate_employment_record_once( + '${TENANT_ID}'::uuid, + '${PERSON_ID}'::uuid, + '00000000-0000-7000-8000-000000000101'::uuid, + '00000000-0000-7000-8000-000000000201'::uuid, + DATE '2026-07-01', + 'voluntary_resignation', + 'separation_packet:sep-2026-002', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-18', + 'employment-separation-key-18', + '00000000-0000-4000-8000-000000000504'::uuid, + '00000000-0000-4000-8000-000000000604'::uuid +);"; } 2>&1)" +stale_status=$? +set -e +if [[ ${stale_status} -eq 0 || "${stale_output}" != *"expected version is stale or unavailable"* ]]; then + echo "stale expected Employment version was not rejected correctly: ${stale_output}" >&2 + exit 1 +fi + +set +e +foreign_tenant_output="$({ foreign_tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT * FROM public.separate_employment_record_once( + '${TENANT_ID}'::uuid, + '${PERSON_ID}'::uuid, + '00000000-0000-7000-8000-000000000102'::uuid, + '00000000-0000-7000-8000-000000000202'::uuid, + DATE '2026-08-01', + 'voluntary_resignation', + 'separation_packet:sep-foreign', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-foreign', + 'employment-separation-key-foreign', + '00000000-0000-4000-8000-000000000505'::uuid, + '00000000-0000-4000-8000-000000000605'::uuid +);"; } 2>&1)" +foreign_tenant_status=$? +set -e +if [[ ${foreign_tenant_status} -eq 0 || "${foreign_tenant_output}" != *"tenant context does not match command tenant"* ]]; then + echo "cross-tenant separation was not rejected before mutation: ${foreign_tenant_output}" >&2 + exit 1 +fi + +set +e +future_output="$({ tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT * FROM public.separate_employment_record_once( + '${TENANT_ID}'::uuid, + '${PERSON_ID}'::uuid, + '00000000-0000-7000-8000-000000000103'::uuid, + '00000000-0000-7000-8000-000000000203'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:sep-future', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-future', + 'employment-separation-key-future', + '00000000-0000-4000-8000-000000000506'::uuid, + '00000000-0000-4000-8000-000000000606'::uuid +);"; } 2>&1)" +future_status=$? +set -e +if [[ ${future_status} -eq 0 || "${future_output}" != *"future Employment version coordination"* ]]; then + echo "future Employment version was not protected from implicit cancellation: ${future_output}" >&2 + exit 1 +fi + +set +e +assignment_output="$({ tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT * FROM public.separate_employment_record_once( + '${TENANT_ID}'::uuid, + '${PERSON_ID}'::uuid, + '00000000-0000-7000-8000-000000000104'::uuid, + '00000000-0000-7000-8000-000000000205'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:sep-assignment', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-assignment', + 'employment-separation-key-assignment', + '00000000-0000-4000-8000-000000000507'::uuid, + '00000000-0000-4000-8000-000000000607'::uuid +);"; } 2>&1)" +assignment_status=$? +set -e +if [[ ${assignment_status} -eq 0 || "${assignment_output}" != *"assignment coordination before termination"* ]]; then + echo "open Assignment was not protected from implicit termination: ${assignment_output}" >&2 + exit 1 +fi + +first_concurrent_output="$(mktemp)" +second_concurrent_output="$(mktemp)" +cleanup() { + rm -f "${first_concurrent_output}" "${second_concurrent_output}" +} +trap cleanup EXIT + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_employment_separation_first' \ +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' >"${first_concurrent_output}" <<'SQL' & +BEGIN; +SELECT employment_record_id, separated_employment_record_version_id, recorded_at, replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000102'::uuid, + '00000000-0000-7000-8000-000000000202'::uuid, + DATE '2026-08-01', + 'voluntary_resignation', + 'separation_packet:sep-concurrent', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-concurrent', + 'employment-separation-key-concurrent', + '00000000-0000-4000-8000-000000000508'::uuid, + '00000000-0000-4000-8000-000000000608'::uuid +); +SELECT pg_sleep(5); +COMMIT; +SQL +first_pid=$! + +first_ready=false +for _ in $(seq 1 80); do + first_state="$(psql "${DATABASE_URL}" -Atqc " + SELECT count(*) + FROM pg_catalog.pg_stat_activity + WHERE application_name = 'orgmetra_employment_separation_first' + AND wait_event = 'PgSleep'; + ")" + if [[ "${first_state}" == "1" ]]; then + first_ready=true + break + fi + sleep 0.05 +done +if [[ "${first_ready}" != "true" ]]; then + set +e + wait "${first_pid}" + first_status=$? + set -e + echo "first separation transaction never became observable; exit_status=${first_status}" >&2 + exit 1 +fi + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_employment_separation_second' \ +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' >"${second_concurrent_output}" <<'SQL' & +SET statement_timeout = '10s'; +SELECT employment_record_id, separated_employment_record_version_id, recorded_at, replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000102'::uuid, + '00000000-0000-7000-8000-000000000202'::uuid, + DATE '2026-08-01', + 'voluntary_resignation', + 'separation_packet:sep-concurrent', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-concurrent', + 'employment-separation-key-concurrent', + '00000000-0000-4000-8000-000000000509'::uuid, + '00000000-0000-4000-8000-000000000609'::uuid +); SQL +second_pid=$! + +lock_graph_observed=false +for _ in $(seq 1 80); do + blocking_count="$(psql "${DATABASE_URL}" -Atqc " + SELECT count(*) + FROM pg_catalog.pg_stat_activity AS second_session + JOIN pg_catalog.pg_stat_activity AS first_session + ON first_session.application_name = 'orgmetra_employment_separation_first' + WHERE second_session.application_name = 'orgmetra_employment_separation_second' + AND second_session.wait_event_type = 'Lock' + AND second_session.wait_event = 'advisory' + AND first_session.pid = ANY(pg_catalog.pg_blocking_pids(second_session.pid)); + ")" + if [[ "${blocking_count}" == "1" ]]; then + lock_graph_observed=true + break + fi + sleep 0.05 +done + +set +e +wait "${first_pid}" +first_status=$? +wait "${second_pid}" +second_status=$? +set -e +if [[ "${lock_graph_observed}" != "true" ]]; then + echo "second exact-key separation never exposed the first backend as its advisory-lock blocker" >&2 + exit 1 +fi +if [[ ${first_status} -ne 0 || ${second_status} -ne 0 ]]; then + echo "concurrent separation sessions failed: first=${first_status} second=${second_status}" >&2 + exit 1 +fi + +first_concurrent_row="$(grep '^00000000-0000-7000-8000-000000000102|' "${first_concurrent_output}" | head -n 1)" +second_concurrent_row="$(grep '^00000000-0000-7000-8000-000000000102|' "${second_concurrent_output}" | head -n 1)" +first_concurrent_identity="$(printf '%s' "${first_concurrent_row}" | cut -d'|' -f1-3)" +second_concurrent_identity="$(printf '%s' "${second_concurrent_row}" | cut -d'|' -f1-3)" +if [[ -z "${first_concurrent_identity}" || "${second_concurrent_identity}" != "${first_concurrent_identity}" ]]; then + echo "concurrent exact-key separation did not converge on one committed identity" >&2 + exit 1 +fi +if [[ "$(printf '%s' "${first_concurrent_row}" | cut -d'|' -f4)" != "f" || "$(printf '%s' "${second_concurrent_row}" | cut -d'|' -f4)" != "t" ]]; then + echo "concurrent separation did not produce one first-write and one replay: first=${first_concurrent_row} second=${second_concurrent_row}" >&2 + exit 1 +fi + +final_counts="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT concat_ws(',', + (SELECT count(*) FROM public.employment_separation_record), + (SELECT count(*) FROM public.audit_event_record WHERE canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated'), + (SELECT count(*) FROM public.people_mutation_idempotency_record WHERE command_route = 'employment-separations') +); +")" +if [[ "${final_counts}" != "2,2,2" ]]; then + echo "rejected/replayed separations changed durable truth unexpectedly: ${final_counts}" >&2 + exit 1 +fi + +echo "PostgreSQL Employment separation transition contract passed" From 57f778a702102db6b471ad969d35b3d7133481ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:18:04 +0900 Subject: [PATCH 143/269] docs(people): record Employment separation truth decision --- ...verned-employment-separation-transition.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/adr/0015-governed-employment-separation-transition.md diff --git a/docs/adr/0015-governed-employment-separation-transition.md b/docs/adr/0015-governed-employment-separation-transition.md new file mode 100644 index 000000000..3e78d173e --- /dev/null +++ b/docs/adr/0015-governed-employment-separation-transition.md @@ -0,0 +1,76 @@ +# ADR 0015: Governed Employment separation transition + +- Status: Proposed +- Date: 2026-09-12 +- Owners: People Core / HRIS +- Related: #314, #302, ADR 0003, ADR 0004, ADR 0005, ADR 0006, ADR 0008 + +## Problem + +Orgmetra already rejects in-place business mutation of `employment_record_version`, so changing `employment_status_code` from `active` or `leave` to `terminated` is intentionally invalid. The People bounded context nevertheless needs an authoritative way to end an Employment before rehire can create a later Employment for the same Person. + +Two fields could otherwise become competing termination truths: `effective_to` on a surviving active/leave version and a separate `terminated` status. A separation command also has to survive retries, concurrent submissions, stale version references, tenant confusion, future-scheduled Employment facts, open Assignments, and audit/outbox failure without leaving partial history. + +## Constraints + +- Person identity and prior Employment identity/history must remain immutable. +- Recorded-time history is correction-not-rewrite. A previously known fact remains queryable at its earlier knowledge coordinate. +- Business-effective and recorded-time intervals are half-open. +- A high-impact separation requires explicit actor, purpose, reason, evidence and human confirmation. +- Tenant context must be bound before acquiring database-global advisory coordination state. +- Assignment lifecycle is owned by the Assignment boundary. Separation may not silently rewrite or close Assignment facts. +- Rehire uses a new Employment identity unless a later, separately reviewed contract explicitly establishes another rule. + +## Considered alternatives + +### Rewrite the current Employment version in place + +Rejected. It destroys the knowledge-time history already protected by ADR 0003 and by the database mutation guard. + +### Use only `effective_to` as the termination fact + +Rejected. `effective_to` is an interval boundary and cannot by itself carry the governed terminal state, reason, evidence, confirmation, audit identity, or retry provenance required for a high-impact lifecycle decision. + +### Keep the old active/leave version open and append an overlapping `terminated` version + +Rejected. Overlapping business intervals would make the current Employment state ambiguous and conflict with the existing bitemporal exclusion contract. + +### Let separation update Assignment rows in the same command + +Rejected. It crosses aggregate ownership and makes one Employment transaction responsible for Assignment policy and recovery. Open or future-effective Assignments instead cause the separation command to fail closed until their owner coordinates them. + +## Decision + +A separation is one governed correction of an exact current-known Employment version. + +1. The command names tenant, Person, Employment, expected Employment version, separation effective date, controlled separation reason, evidence reference/version, actor, `workforce_admin` purpose, human confirmation and idempotency key. +2. Tenant context is checked before the exact tenant+route+idempotency-key advisory transaction lock. +3. A matching idempotency key replays the first durable result; a changed semantic command under the same key fails closed. +4. The Employment anchor is locked and the exact expected version must still be current in recorded time and `active` or `leave` at the requested effective date. +5. Any other current-known Employment version that would overlap the terminal interval requires explicit future-version coordination rather than implicit cancellation. Any Assignment that would remain effective on or after separation likewise blocks the command. +6. The database reads one post-lock `clock_timestamp()`. The expected version's recorded interval closes at that instant. When the separation date is after the expected version's `effective_from`, a replacement continuation version preserves the pre-separation interval `[effective_from, separation_effective_on)`. A new `terminated` version owns `[separation_effective_on, infinity)`. +7. The continuation `effective_to` is therefore a structural interval boundary, not an independent termination truth. The terminal successor status plus `employment_separation_record` is the authoritative separation fact. +8. The database generates the CloudEvents-compatible `employment_separated` audit envelope from the same command and post-lock timestamp, persists audit/outbox state, append-only separation provenance and the People idempotency binding in the same transaction. +9. Rehire, when implemented under #302, must create a new Employment for the existing Person and cite a successfully separated prior Employment. It must not reopen the terminated Employment or infer authority from an old candidate-worker conversion. + +## Data ownership + +`employment_record` remains the durable Employment identity. `employment_record_version` remains the bitemporal business-fact history. `employment_separation_record` stores PII-minimized decision provenance linking the prior version, optional continuation version, terminal version, governed decision metadata and audit event. It is append-only, tenant-qualified and protected by forced RLS. + +No cross-service SQL or copied HR truth is introduced. Keyverse remains the identity/policy backend; external workflow, payroll, identity deprovisioning and notification work belongs after the transaction through owned contracts/events. + +## Failure and concurrency semantics + +A stale expected version, wrong Person/Employment binding, wrong tenant context, semantic idempotency conflict, future Employment version, or Assignment requiring coordination fails before any durable separation state commits. Exact-key concurrent requests serialize on PostgreSQL advisory transaction state and converge on one first result plus replay. A test is acceptable only when the second backend is observed waiting on the first through PostgreSQL's lock graph; elapsed time alone is not serialization evidence. + +## Evidence required before Accepted + +- PostgreSQL contract applies migrations through `0014_employment_separation_transition.sql` on PostgreSQL 16. +- Current knowledge contains one pre-separation continuation and one terminal version without effective overlap, while an earlier knowledge coordinate still returns the pre-correction active/leave fact. +- Audit event `time` equals the database-owned separation `recorded_at` and audit/outbox/idempotency/separation facts are one-transaction durable. +- Same-key replay returns the first terminal version and timestamp; changed semantics under the key are rejected. +- Cross-tenant, stale-version, future-version and open-Assignment hostile cases fail closed. +- Concurrent exact-key first attempts expose the real PostgreSQL advisory-lock blocker relationship and converge on one durable separation. +- Canonical Foundation owner registers the focused PostgreSQL contract without duplicating workflow ownership and exact-head hosted evidence is green. + +Until those conditions are present on the protected stack, this ADR remains Proposed. From c887b49f99014ccfdd6c72276521bc899e2e038d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:19:11 +0900 Subject: [PATCH 144/269] docs(people): trace Employment separation acceptance --- docs/traceability/employment-separation.md | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docs/traceability/employment-separation.md diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md new file mode 100644 index 000000000..c3e8de8b7 --- /dev/null +++ b/docs/traceability/employment-separation.md @@ -0,0 +1,30 @@ +# Employment separation traceability + +## Scope + +This trace binds issue #314 to the first authoritative Employment separation slice. It does not declare rehire (#302) complete. Rehire remains downstream and must create a new Employment for the existing Person after a durable separation exists. + +| Requirement | Authority | Executable evidence | Maturity | +|---|---|---|---| +| Correction-not-rewrite separation | ADR 0003 + ADR 0015 | `database/migrations/0014_employment_separation_transition.sql` closes only the expected recorded interval and inserts replacement business-time facts | implemented_on_active_pr | +| One authoritative separation fact | ADR 0015 | `employment_separation_record` links prior, optional continuation and terminal Employment versions; `terminated` successor is the terminal fact while continuation `effective_to` is only its interval boundary | implemented_on_active_pr | +| Exact tenant/Person/Employment/version binding | #314 + ADR 0015 | `separate_employment_record_once(...)` checks tenant context before advisory locking, locks the Employment anchor and requires the expected current-recorded version | implemented_on_active_pr | +| Future Employment facts are not silently cancelled | ADR 0015 | any other current-known Employment version overlapping `[separation_effective_on, infinity)` fails closed and requires owner coordination | implemented_on_active_pr | +| Assignment ownership is preserved | Context Map + ADR 0015 | any Assignment still effective on/after separation fails closed; separation never rewrites Assignment rows | implemented_on_active_pr | +| Database-owned recorded time | #314 | one post-lock `clock_timestamp()` closes prior recorded history, opens replacement versions, stamps separation provenance and is serialized into the audit event `time` | implemented_on_active_pr | +| High-impact governance evidence | ADR 0006 + ADR 0008 + ADR 0015 | controlled reason, evidence reference/version, actor, `workforce_admin` purpose and human confirmation are mandatory; audit/outbox is persisted in the same transaction | implemented_on_active_pr | +| Retry safety | People mutation idempotency contract | exact tenant+route+idempotency-key advisory lock; same semantic command replays first terminal version/timestamp; changed command under same key fails | implemented_on_active_pr | +| Real concurrent-first serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on an advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | +| Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | +| Canonical CI ownership | #311 | focused PostgreSQL contract must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | +| Rehire | #302 | existing Person + new Employment must cite authoritative prior separation and fresh rehire authority | not_started | + +## Current acceptance boundary + +`database/migrations/0014_employment_separation_transition.sql`, ADR 0015 and the focused PostgreSQL contract are ordinary-forward changes on the canonical People PR. The existing Foundation workflow remains owned by its canonical Foundation stack, so the new PostgreSQL contract is not treated as hosted GREEN until that owner registers it and an exact-head PostgreSQL run passes. + +Static repository validation and unrelated historical checks must not be described as proof of the new separation transaction. Likewise bot review success is review evidence, not runtime acceptance or an independent approval. + +## Next owner handoff + +Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` as a PostgreSQL contract rather than adding a filename-specific workflow branch. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. Once the owner integrates that contract, the People stack can use the resulting exact-head runtime evidence to decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From 36e901e92aacb8aa4d64d5898a8514ec99856890 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:22:42 +0900 Subject: [PATCH 145/269] fix(people): revoke public separation capability --- ...oyment_separation_capability_hardening.sql | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 database/migrations/0015_employment_separation_capability_hardening.sql diff --git a/database/migrations/0015_employment_separation_capability_hardening.sql b/database/migrations/0015_employment_separation_capability_hardening.sql new file mode 100644 index 000000000..e45e7ae15 --- /dev/null +++ b/database/migrations/0015_employment_separation_capability_hardening.sql @@ -0,0 +1,32 @@ +-- Keep the high-impact Employment separation transition behind an explicitly +-- granted database capability. PostgreSQL grants EXECUTE on new functions to +-- PUBLIC by default; leaving that default in place would let any sufficiently +-- privileged application role bypass the reviewed People/Keyverse boundary. +-- +-- The function remains SECURITY INVOKER. A later service-role grant must be +-- explicit and must not turn this function into a privilege-escalation owner. + +BEGIN; + +SET LOCAL search_path = pg_catalog, public; + +REVOKE EXECUTE ON FUNCTION public.separate_employment_record_once( + uuid, + uuid, + uuid, + uuid, + date, + text, + text, + text, + text, + text, + text, + text, + uuid, + uuid +) FROM PUBLIC; + +REVOKE EXECUTE ON FUNCTION public.reject_employment_separation_truncate() FROM PUBLIC; + +COMMIT; From d297eab7697f7a5fd012321fc6c3bf359958b636 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:23:08 +0900 Subject: [PATCH 146/269] test(people): prove separation SQL capability is fail closed --- ...ployment_separation_capability_postgres.sh | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/test_employment_separation_capability_postgres.sh diff --git a/tests/test_employment_separation_capability_postgres.sh b/tests/test_employment_separation_capability_postgres.sh new file mode 100644 index 000000000..74a70adf6 --- /dev/null +++ b/tests/test_employment_separation_capability_postgres.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0003_audit_outbox_persistence.sql \ + database/migrations/0004_outbox_delivery_claim.sql \ + database/migrations/0005_outbox_delivery_finalization.sql \ + database/migrations/0006_outbox_delivery_dead_letter.sql \ + database/migrations/0007_outbox_retry_exhaustion.sql \ + database/migrations/0008_audit_outbox_review_hardening.sql \ + database/migrations/0009_candidate_worker_conversion_governance.sql \ + database/migrations/0010_validity_study_case_integrity.sql \ + database/migrations/0011_criterion_observation_scope.sql \ + database/migrations/0012_people_mutation_idempotency.sql \ + database/migrations/0013_job_analysis_snapshot.sql \ + database/migrations/0014_employment_separation_transition.sql \ + database/migrations/0015_employment_separation_capability_hardening.sql; do + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +public_execute_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT count(*) +FROM pg_catalog.pg_proc AS function_record +CROSS JOIN LATERAL pg_catalog.aclexplode( + COALESCE( + function_record.proacl, + pg_catalog.acldefault('f', function_record.proowner) + ) +) AS function_acl +WHERE function_record.oid = 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)'::regprocedure + AND function_acl.grantee = 0 + AND function_acl.privilege_type = 'EXECUTE'; +")" +if [[ "${public_execute_count}" != "0" ]]; then + echo "PUBLIC unexpectedly retains Employment separation EXECUTE capability" >&2 + exit 1 +fi + +trigger_execute_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT count(*) +FROM pg_catalog.pg_proc AS function_record +CROSS JOIN LATERAL pg_catalog.aclexplode( + COALESCE( + function_record.proacl, + pg_catalog.acldefault('f', function_record.proowner) + ) +) AS function_acl +WHERE function_record.oid = 'public.reject_employment_separation_truncate()'::regprocedure + AND function_acl.grantee = 0 + AND function_acl.privilege_type = 'EXECUTE'; +")" +if [[ "${trigger_execute_count}" != "0" ]]; then + echo "PUBLIC unexpectedly retains Employment separation trigger EXECUTE capability" >&2 + exit 1 +fi + +security_invoker="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT (NOT prosecdef)::text +FROM pg_catalog.pg_proc +WHERE oid = 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)'::regprocedure; +")" +if [[ "${security_invoker}" != "true" ]]; then + echo "Employment separation function unexpectedly runs as SECURITY DEFINER" >&2 + exit 1 +fi + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +CREATE ROLE orgmetra_employment_separation_probe + NOLOGIN + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOBYPASSRLS; +SQL + +set +e +probe_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +SET ROLE orgmetra_employment_separation_probe; +SET orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; +SELECT * +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000101'::uuid, + '00000000-0000-7000-8000-000000000201'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:probe', + 'v1', + 'keyverse_subject:probe', + 'workforce_admin', + 'human_confirmation:probe', + 'employment-separation-probe-key', + '00000000-0000-4000-8000-000000000501'::uuid, + '00000000-0000-4000-8000-000000000601'::uuid +); +SQL +} 2>&1)" +probe_status=$? +set -e + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +DROP ROLE orgmetra_employment_separation_probe; +SQL + +if [[ ${probe_status} -eq 0 ]]; then + echo "ungranted probe role unexpectedly executed Employment separation" >&2 + exit 1 +fi +if [[ "${probe_output}" != *"permission denied for function separate_employment_record_once"* ]]; then + echo "probe role failed for an unexpected reason: ${probe_output}" >&2 + exit 1 +fi + +echo "PostgreSQL Employment separation capability contract passed" From fdb33797fde1efc215ab0dcde474ce82e7d27dfd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:27:10 +0900 Subject: [PATCH 147/269] test(people): isolate separation capability probe lifecycle --- ...ployment_separation_capability_postgres.sh | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/test_employment_separation_capability_postgres.sh b/tests/test_employment_separation_capability_postgres.sh index 74a70adf6..9d98db4a1 100644 --- a/tests/test_employment_separation_capability_postgres.sh +++ b/tests/test_employment_separation_capability_postgres.sh @@ -68,8 +68,27 @@ if [[ "${security_invoker}" != "true" ]]; then exit 1 fi -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' -CREATE ROLE orgmetra_employment_separation_probe +probe_suffix="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc "SELECT substr(replace(gen_random_uuid()::text, '-', ''), 1, 24);")" +if [[ ! "${probe_suffix}" =~ ^[0-9a-f]{24}$ ]]; then + echo "failed to generate collision-resistant Employment separation probe identity" >&2 + exit 1 +fi +probe_role="orgmetra_employment_separation_probe_${probe_suffix}" +probe_role_created=false + +cleanup_probe_role_best_effort() { + if [[ "${probe_role_created}" != "true" ]]; then + return + fi + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v probe_role="${probe_role}" >/dev/null 2>&1 <<'SQL' || true +DROP OWNED BY :"probe_role"; +DROP ROLE :"probe_role"; +SQL +} +trap cleanup_probe_role_best_effort EXIT + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v probe_role="${probe_role}" <<'SQL' +CREATE ROLE :"probe_role" NOLOGIN NOSUPERUSER NOCREATEDB @@ -77,10 +96,11 @@ CREATE ROLE orgmetra_employment_separation_probe NOINHERIT NOBYPASSRLS; SQL +probe_role_created=true set +e -probe_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' -SET ROLE orgmetra_employment_separation_probe; +probe_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v probe_role="${probe_role}" <<'SQL' +SET ROLE :"probe_role"; SET orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; SELECT * FROM public.separate_employment_record_once( @@ -104,9 +124,16 @@ SQL probe_status=$? set -e -psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' -DROP ROLE orgmetra_employment_separation_probe; +if ! psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v probe_role="${probe_role}" <<'SQL' +DROP OWNED BY :"probe_role"; +DROP ROLE :"probe_role"; SQL +then + echo "failed to clean Employment separation probe role after successful acceptance path" >&2 + exit 1 +fi +probe_role_created=false +trap - EXIT if [[ ${probe_status} -eq 0 ]]; then echo "ungranted probe role unexpectedly executed Employment separation" >&2 From b249c4392c624cff52efbf19c416ced7455d87c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:33:19 +0900 Subject: [PATCH 148/269] test(people): require least-privilege separation executor --- ...ployment_separation_capability_postgres.sh | 111 ++++++++++++++++-- 1 file changed, 103 insertions(+), 8 deletions(-) diff --git a/tests/test_employment_separation_capability_postgres.sh b/tests/test_employment_separation_capability_postgres.sh index 9d98db4a1..7204e87a4 100644 --- a/tests/test_employment_separation_capability_postgres.sh +++ b/tests/test_employment_separation_capability_postgres.sh @@ -22,7 +22,9 @@ for migration in \ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" done -public_execute_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +separation_signature='public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)' + +public_execute_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v signature="${separation_signature}" -Atqc " SELECT count(*) FROM pg_catalog.pg_proc AS function_record CROSS JOIN LATERAL pg_catalog.aclexplode( @@ -31,7 +33,7 @@ CROSS JOIN LATERAL pg_catalog.aclexplode( pg_catalog.acldefault('f', function_record.proowner) ) ) AS function_acl -WHERE function_record.oid = 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)'::regprocedure +WHERE function_record.oid = :'signature'::regprocedure AND function_acl.grantee = 0 AND function_acl.privilege_type = 'EXECUTE'; ")" @@ -58,13 +60,106 @@ if [[ "${trigger_execute_count}" != "0" ]]; then exit 1 fi -security_invoker="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " -SELECT (NOT prosecdef)::text -FROM pg_catalog.pg_proc -WHERE oid = 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)'::regprocedure; +capability_role_contract="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT pg_catalog.coalesce( + pg_catalog.string_agg( + role.rolname || '|' || role.rolcanlogin::text || '|' || role.rolsuper::text || '|' || role.rolcreatedb::text || '|' || role.rolcreaterole::text || '|' || role.rolreplication::text || '|' || role.rolbypassrls::text, + E'\\n' ORDER BY role.rolname + ), + '' +) +FROM pg_catalog.pg_roles AS role +WHERE role.rolname IN ( + 'orgmetra_employment_separation_executor', + 'orgmetra_employment_separation_owner' +); +")" +expected_role_contract=$'orgmetra_employment_separation_executor|false|false|false|false|false|false\norgmetra_employment_separation_owner|false|false|false|false|false|false' +if [[ "${capability_role_contract}" != "${expected_role_contract}" ]]; then + echo "Employment separation capability roles are absent or over-privileged: ${capability_role_contract}" >&2 + exit 1 +fi + +function_security_contract="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v signature="${separation_signature}" -Atqc " +SELECT function_record.prosecdef::text || '|' || owner_role.rolname +FROM pg_catalog.pg_proc AS function_record +JOIN pg_catalog.pg_roles AS owner_role + ON owner_role.oid = function_record.proowner +WHERE function_record.oid = :'signature'::regprocedure; ")" -if [[ "${security_invoker}" != "true" ]]; then - echo "Employment separation function unexpectedly runs as SECURITY DEFINER" >&2 +if [[ "${function_security_contract}" != "true|orgmetra_employment_separation_owner" ]]; then + echo "Employment separation function is not owned by the dedicated SECURITY DEFINER authority: ${function_security_contract}" >&2 + exit 1 +fi + +executor_execute="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v signature="${separation_signature}" -Atqc " +SELECT pg_catalog.has_function_privilege( + 'orgmetra_employment_separation_executor', + :'signature', + 'EXECUTE' +)::text; +")" +if [[ "${executor_execute}" != "true" ]]; then + echo "Employment separation executor lacks the reviewed function capability" >&2 + exit 1 +fi + +executor_direct_dml="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +WITH relation(name) AS ( + VALUES + ('public.employment_record'), + ('public.employment_record_version'), + ('public.assignment_record'), + ('public.employment_separation_record'), + ('public.people_mutation_idempotency_record'), + ('public.audit_event_record'), + ('public.outbox_delivery_record') +) +SELECT pg_catalog.bool_or( + pg_catalog.has_table_privilege('orgmetra_employment_separation_executor', name, 'SELECT') + OR pg_catalog.has_table_privilege('orgmetra_employment_separation_executor', name, 'INSERT') + OR pg_catalog.has_table_privilege('orgmetra_employment_separation_executor', name, 'UPDATE') + OR pg_catalog.has_table_privilege('orgmetra_employment_separation_executor', name, 'DELETE') + OR pg_catalog.has_table_privilege('orgmetra_employment_separation_executor', name, 'TRUNCATE') +)::text +FROM relation; +")" +if [[ "${executor_direct_dml}" != "false" ]]; then + echo "Employment separation executor unexpectedly has direct HR/audit table DML capability" >&2 + exit 1 +fi + +set +e +executor_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +SET ROLE orgmetra_employment_separation_executor; +SET orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; +SELECT * +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000101'::uuid, + '00000000-0000-7000-8000-000000000201'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:executor_probe', + 'v1', + 'keyverse_subject:executor_probe', + 'workforce_admin', + 'human_confirmation:executor_probe', + 'employment-separation-executor-probe', + '00000000-0000-4000-8000-000000000501'::uuid, + '00000000-0000-4000-8000-000000000601'::uuid +); +SQL +} 2>&1)" +executor_status=$? +set -e +if [[ ${executor_status} -eq 0 ]]; then + echo "executor probe unexpectedly found a target Employment" >&2 + exit 1 +fi +if [[ "${executor_output}" != *"employment separation target does not match tenant person and employment"* ]]; then + echo "executor did not cross the reviewed function boundary with deny-default table privileges: ${executor_output}" >&2 exit 1 fi From 351e489658172aff96d18b4d0aa372590d3deec1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:33:57 +0900 Subject: [PATCH 149/269] fix(people): isolate separation mutation capability --- ...loyment_separation_executor_capability.sql | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 database/migrations/0016_employment_separation_executor_capability.sql diff --git a/database/migrations/0016_employment_separation_executor_capability.sql b/database/migrations/0016_employment_separation_executor_capability.sql new file mode 100644 index 000000000..a599db9dc --- /dev/null +++ b/database/migrations/0016_employment_separation_executor_capability.sql @@ -0,0 +1,179 @@ +-- Make the governed Employment separation transition callable without granting +-- the application direct mutation rights on People or audit/outbox relations. +-- The externally assignable executor receives only function EXECUTE; a dedicated +-- NOLOGIN/NOBYPASSRLS owner holds the minimum database privileges used by the +-- reviewed SECURITY DEFINER boundary. + +-- Capability role names are security boundaries. Reusing an existing cluster +-- role could preserve undisclosed memberships or ACLs. Fail before changing any +-- project object so a collision cannot leave a partially elevated boundary. +DO $orgmetra_employment_separation_role_preflight$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname IN ( + 'orgmetra_employment_separation_owner', + 'orgmetra_employment_separation_executor' + ) + ) THEN + RAISE EXCEPTION 'pre-existing Employment separation capability role is not accepted' + USING ERRCODE = '42710'; + END IF; +END; +$orgmetra_employment_separation_role_preflight$; + +BEGIN; + +SET LOCAL search_path = pg_catalog, public; + +CREATE ROLE orgmetra_employment_separation_owner + NOLOGIN + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + +CREATE ROLE orgmetra_employment_separation_executor + NOLOGIN + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + +GRANT USAGE ON SCHEMA public + TO orgmetra_employment_separation_owner, + orgmetra_employment_separation_executor; + +-- The function owner is not a login/runtime principal. These grants are only the +-- capabilities needed by separate_employment_record_once and its audited write +-- helper while FORCE RLS remains active under the caller's tenant context. +GRANT SELECT ON TABLE + public.employment_record, + public.employment_record_version, + public.assignment_record, + public.employment_separation_record, + public.people_mutation_idempotency_record +TO orgmetra_employment_separation_owner; + +-- SELECT ... FOR UPDATE on the aggregate roots requires update capability. The +-- only business mutation performed by the boundary is recorded_to on the exact +-- current Employment version; the owner remains NOLOGIN and is not grantable as +-- the application capability. +GRANT UPDATE (recorded_from) ON TABLE public.employment_record + TO orgmetra_employment_separation_owner; +GRANT UPDATE (recorded_to) ON TABLE public.employment_record_version + TO orgmetra_employment_separation_owner; + +GRANT INSERT ON TABLE + public.employment_record_version, + public.employment_separation_record, + public.people_mutation_idempotency_record, + public.audit_event_record, + public.outbox_delivery_record +TO orgmetra_employment_separation_owner; + +GRANT EXECUTE ON FUNCTION public.current_tenant_record_id() + TO orgmetra_employment_separation_owner; +GRANT EXECUTE ON FUNCTION public.is_operational_uuid(uuid) + TO orgmetra_employment_separation_owner; +GRANT EXECUTE ON FUNCTION public.digest(bytea, text) + TO orgmetra_employment_separation_owner; +GRANT EXECUTE ON FUNCTION public.validate_audit_event_envelope(text, uuid, uuid, text) + TO orgmetra_employment_separation_owner; +GRANT EXECUTE ON FUNCTION public.record_audit_outbox_event(uuid, uuid, uuid, text, text, text) + TO orgmetra_employment_separation_owner; + +-- ALTER FUNCTION OWNER requires CREATE on the containing schema for the target +-- owner. Grant that authority only for this atomic handoff and revoke it before +-- commit. PUBLIC remains unable to execute the high-impact mutation boundary. +GRANT CREATE ON SCHEMA public TO orgmetra_employment_separation_owner; +ALTER FUNCTION public.separate_employment_record_once( + uuid, + uuid, + uuid, + uuid, + date, + text, + text, + text, + text, + text, + text, + text, + uuid, + uuid +) OWNER TO orgmetra_employment_separation_owner; +ALTER FUNCTION public.separate_employment_record_once( + uuid, + uuid, + uuid, + uuid, + date, + text, + text, + text, + text, + text, + text, + text, + uuid, + uuid +) SECURITY DEFINER; +REVOKE CREATE ON SCHEMA public FROM orgmetra_employment_separation_owner; +REVOKE ALL ON FUNCTION public.separate_employment_record_once( + uuid, + uuid, + uuid, + uuid, + date, + text, + text, + text, + text, + text, + text, + text, + uuid, + uuid +) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.separate_employment_record_once( + uuid, + uuid, + uuid, + uuid, + date, + text, + text, + text, + text, + text, + text, + text, + uuid, + uuid +) TO orgmetra_employment_separation_executor; + +COMMENT ON FUNCTION public.separate_employment_record_once( + uuid, + uuid, + uuid, + uuid, + date, + text, + text, + text, + text, + text, + text, + text, + uuid, + uuid +) IS + 'Performs one tenant-bound, idempotent, bitemporal Employment separation. The SECURITY DEFINER function is owned by a dedicated NOLOGIN/NOBYPASSRLS role with only the reviewed People and audit/outbox privileges required by this transaction. The externally assignable executor has schema USAGE plus function EXECUTE only and cannot bypass the governed transition with direct table DML. FORCE RLS and the explicit current_tenant_record_id check remain active.'; + +COMMIT; From a0569d5f7771a9b8ded078ade72076f8758444b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:39:43 +0900 Subject: [PATCH 150/269] test(people): exercise separation executor boundary --- ...employment_separation_capability_postgres.sh | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/test_employment_separation_capability_postgres.sh b/tests/test_employment_separation_capability_postgres.sh index 7204e87a4..1436bebc6 100644 --- a/tests/test_employment_separation_capability_postgres.sh +++ b/tests/test_employment_separation_capability_postgres.sh @@ -18,13 +18,12 @@ for migration in \ database/migrations/0012_people_mutation_idempotency.sql \ database/migrations/0013_job_analysis_snapshot.sql \ database/migrations/0014_employment_separation_transition.sql \ - database/migrations/0015_employment_separation_capability_hardening.sql; do + database/migrations/0015_employment_separation_capability_hardening.sql \ + database/migrations/0016_employment_separation_executor_capability.sql; do psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" done -separation_signature='public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)' - -public_execute_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v signature="${separation_signature}" -Atqc " +public_execute_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " SELECT count(*) FROM pg_catalog.pg_proc AS function_record CROSS JOIN LATERAL pg_catalog.aclexplode( @@ -33,7 +32,7 @@ CROSS JOIN LATERAL pg_catalog.aclexplode( pg_catalog.acldefault('f', function_record.proowner) ) ) AS function_acl -WHERE function_record.oid = :'signature'::regprocedure +WHERE function_record.oid = 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)'::regprocedure AND function_acl.grantee = 0 AND function_acl.privilege_type = 'EXECUTE'; ")" @@ -80,22 +79,22 @@ if [[ "${capability_role_contract}" != "${expected_role_contract}" ]]; then exit 1 fi -function_security_contract="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v signature="${separation_signature}" -Atqc " +function_security_contract="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " SELECT function_record.prosecdef::text || '|' || owner_role.rolname FROM pg_catalog.pg_proc AS function_record JOIN pg_catalog.pg_roles AS owner_role ON owner_role.oid = function_record.proowner -WHERE function_record.oid = :'signature'::regprocedure; +WHERE function_record.oid = 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)'::regprocedure; ")" if [[ "${function_security_contract}" != "true|orgmetra_employment_separation_owner" ]]; then echo "Employment separation function is not owned by the dedicated SECURITY DEFINER authority: ${function_security_contract}" >&2 exit 1 fi -executor_execute="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -v signature="${separation_signature}" -Atqc " +executor_execute="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " SELECT pg_catalog.has_function_privilege( 'orgmetra_employment_separation_executor', - :'signature', + 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)', 'EXECUTE' )::text; ")" From e408bc8410047e9c827d055a05411ece020ebe85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:40:44 +0900 Subject: [PATCH 151/269] docs(people): record separation capability boundary --- ...verned-employment-separation-transition.md | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/adr/0015-governed-employment-separation-transition.md b/docs/adr/0015-governed-employment-separation-transition.md index 3e78d173e..2a089e8bf 100644 --- a/docs/adr/0015-governed-employment-separation-transition.md +++ b/docs/adr/0015-governed-employment-separation-transition.md @@ -11,6 +11,8 @@ Orgmetra already rejects in-place business mutation of `employment_record_versio Two fields could otherwise become competing termination truths: `effective_to` on a surviving active/leave version and a separate `terminated` status. A separation command also has to survive retries, concurrent submissions, stale version references, tenant confusion, future-scheduled Employment facts, open Assignments, and audit/outbox failure without leaving partial history. +The persistence function is also a high-impact database capability. Revoking PUBLIC execution while leaving it `SECURITY INVOKER` is not a complete runtime boundary: a service principal would need the underlying People and audit/outbox DML rights merely to invoke the function, which would let that principal bypass the governed transition with direct SQL. + ## Constraints - Person identity and prior Employment identity/history must remain immutable. @@ -20,6 +22,7 @@ Two fields could otherwise become competing termination truths: `effective_to` o - Tenant context must be bound before acquiring database-global advisory coordination state. - Assignment lifecycle is owned by the Assignment boundary. Separation may not silently rewrite or close Assignment facts. - Rehire uses a new Employment identity unless a later, separately reviewed contract explicitly establishes another rule. +- The externally assignable separation capability must not carry direct People/audit/outbox table DML or RLS-bypass authority. ## Considered alternatives @@ -39,6 +42,14 @@ Rejected. Overlapping business intervals would make the current Employment state Rejected. It crosses aggregate ownership and makes one Employment transaction responsible for Assignment policy and recovery. Open or future-effective Assignments instead cause the separation command to fail closed until their owner coordinates them. +### Keep the separation function as SECURITY INVOKER and grant the service its table privileges + +Rejected. The application would then hold direct `employment_record_version`, separation/idempotency and audit/outbox mutation capabilities outside the reviewed function contract. Revoking PUBLIC function execution would not prevent bypass of its tenant, replay, evidence and history rules. + +### Make the runtime application own the SECURITY DEFINER function + +Rejected. A login/runtime identity must not become the privileged function owner. Ownership and invocation are separate capabilities. + ## Decision A separation is one governed correction of an exact current-known Employment version. @@ -52,6 +63,8 @@ A separation is one governed correction of an exact current-known Employment ver 7. The continuation `effective_to` is therefore a structural interval boundary, not an independent termination truth. The terminal successor status plus `employment_separation_record` is the authoritative separation fact. 8. The database generates the CloudEvents-compatible `employment_separated` audit envelope from the same command and post-lock timestamp, persists audit/outbox state, append-only separation provenance and the People idempotency binding in the same transaction. 9. Rehire, when implemented under #302, must create a new Employment for the existing Person and cite a successfully separated prior Employment. It must not reopen the terminated Employment or infer authority from an old candidate-worker conversion. +10. The database execution boundary is capability-separated. `orgmetra_employment_separation_owner` is a dedicated `NOLOGIN`/`NOBYPASSRLS` owner of the `SECURITY DEFINER` function and receives only the reviewed People/audit/outbox privileges needed by that transaction. `orgmetra_employment_separation_executor` is a distinct `NOLOGIN`/`NOBYPASSRLS` role with schema `USAGE` and function `EXECUTE` only. An application login may be granted the executor capability operationally; it must not be granted the owner role or direct table DML as a substitute. +11. The SECURITY DEFINER boundary retains the fixed `pg_catalog, public, pg_temp` search path, explicit tenant-context check and FORCE RLS. Ownership transfer receives `CREATE` on `public` only inside the atomic migration and revokes it before commit. ## Data ownership @@ -59,18 +72,25 @@ A separation is one governed correction of an exact current-known Employment ver No cross-service SQL or copied HR truth is introduced. Keyverse remains the identity/policy backend; external workflow, payroll, identity deprovisioning and notification work belongs after the transaction through owned contracts/events. +The separation owner/executor roles are database capabilities, not HR identities. They do not replace Keyverse authentication/authorization, Person identity, Employment truth or human confirmation. + ## Failure and concurrency semantics A stale expected version, wrong Person/Employment binding, wrong tenant context, semantic idempotency conflict, future Employment version, or Assignment requiring coordination fails before any durable separation state commits. Exact-key concurrent requests serialize on PostgreSQL advisory transaction state and converge on one first result plus replay. A test is acceptable only when the second backend is observed waiting on the first through PostgreSQL's lock graph; elapsed time alone is not serialization evidence. +Capability migration fails before project-object elevation if either reserved separation role name already exists. This prevents an existing role with undisclosed membership/ACL state from being silently reused as the privileged owner or executor. + ## Evidence required before Accepted -- PostgreSQL contract applies migrations through `0014_employment_separation_transition.sql` on PostgreSQL 16. +- PostgreSQL contract applies migrations through `0016_employment_separation_executor_capability.sql` on PostgreSQL 16. - Current knowledge contains one pre-separation continuation and one terminal version without effective overlap, while an earlier knowledge coordinate still returns the pre-correction active/leave fact. - Audit event `time` equals the database-owned separation `recorded_at` and audit/outbox/idempotency/separation facts are one-transaction durable. - Same-key replay returns the first terminal version and timestamp; changed semantics under the key are rejected. - Cross-tenant, stale-version, future-version and open-Assignment hostile cases fail closed. - Concurrent exact-key first attempts expose the real PostgreSQL advisory-lock blocker relationship and converge on one durable separation. -- Canonical Foundation owner registers the focused PostgreSQL contract without duplicating workflow ownership and exact-head hosted evidence is green. +- PUBLIC and an unrelated `NOLOGIN`/`NOBYPASSRLS` probe cannot execute the function. +- The dedicated executor can cross the function boundary but has no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE capability on the governed People/audit/outbox relations. +- The function is owned by the dedicated `NOLOGIN`/`NOBYPASSRLS` owner and executes as SECURITY DEFINER while FORCE RLS remains effective under the caller-supplied tenant context. +- Canonical Foundation owner registers both focused PostgreSQL contracts without duplicating workflow ownership and exact-head hosted evidence is green. Until those conditions are present on the protected stack, this ADR remains Proposed. From 30960c55e4b36e721d282a0880e193188de5162c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:40:59 +0900 Subject: [PATCH 152/269] docs(people): trace separation executor capability --- docs/traceability/employment-separation.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index c3e8de8b7..f827cb88d 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -14,17 +14,24 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Database-owned recorded time | #314 | one post-lock `clock_timestamp()` closes prior recorded history, opens replacement versions, stamps separation provenance and is serialized into the audit event `time` | implemented_on_active_pr | | High-impact governance evidence | ADR 0006 + ADR 0008 + ADR 0015 | controlled reason, evidence reference/version, actor, `workforce_admin` purpose and human confirmation are mandatory; audit/outbox is persisted in the same transaction | implemented_on_active_pr | | Retry safety | People mutation idempotency contract | exact tenant+route+idempotency-key advisory lock; same semantic command replays first terminal version/timestamp; changed command under same key fails | implemented_on_active_pr | +| Deny-default SQL capability | ADR 0015 | migrations `0015`/`0016` revoke PUBLIC, create separate `NOLOGIN`/`NOBYPASSRLS` owner and executor roles, move the function to the owner as SECURITY DEFINER, revoke temporary schema CREATE, and grant the executor function EXECUTE only | implemented_on_active_pr | +| No direct DML bypass from runtime capability | ADR 0015 | `tests/test_employment_separation_capability_postgres.sh` requires the executor to have no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE rights on governed People/audit/outbox relations while still crossing the function boundary | awaiting_foundation_registration | +| Capability-test failure isolation | #314 | the unrelated probe role uses collision-resistant per-execution identity; failure cleanup is best-effort without masking the causal error, while nominal success requires strict verified role cleanup | implemented_on_active_pr | | Real concurrent-first serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on an advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | -| Canonical CI ownership | #311 | focused PostgreSQL contract must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | +| Canonical CI ownership | #311 | both focused PostgreSQL contracts must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | | Rehire | #302 | existing Person + new Employment must cite authoritative prior separation and fresh rehire authority | not_started | ## Current acceptance boundary -`database/migrations/0014_employment_separation_transition.sql`, ADR 0015 and the focused PostgreSQL contract are ordinary-forward changes on the canonical People PR. The existing Foundation workflow remains owned by its canonical Foundation stack, so the new PostgreSQL contract is not treated as hosted GREEN until that owner registers it and an exact-head PostgreSQL run passes. +`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015 and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is now separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. -Static repository validation and unrelated historical checks must not be described as proof of the new separation transaction. Likewise bot review success is review evidence, not runtime acceptance or an independent approval. +The existing Foundation workflow remains owned by its canonical Foundation stack. The new PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. ## Next owner handoff -Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` as a PostgreSQL contract rather than adding a filename-specific workflow branch. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. Once the owner integrates that contract, the People stack can use the resulting exact-head runtime evidence to decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. +Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL contracts rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. + +The People runtime owner must then bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. + +Once those owner boundaries are reconciled and exact-head hosted evidence is green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From b1d2b196cc387b9107e27ec2391c6d5cc84061d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:43:07 +0900 Subject: [PATCH 153/269] test(people): require authorized separation application boundary --- .../test_employment_separation_application.py | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 services/people-api/tests/test_employment_separation_application.py diff --git a/services/people-api/tests/test_employment_separation_application.py b/services/people-api/tests/test_employment_separation_application.py new file mode 100644 index 000000000..7bf8082f7 --- /dev/null +++ b/services/people-api/tests/test_employment_separation_application.py @@ -0,0 +1,221 @@ +"""Executable application contracts for governed Employment separation.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.separation import ( + EmploymentSeparationCommand, + EmploymentSeparationIntegrityError, + EmploymentSeparationPort, + EmploymentSeparationResult, + separate_employment_record, +) + +TENANT = UUID("0198a412-8000-7000-8000-000000000001") +PERSON = UUID("0198a412-8000-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-8000-7000-8000-000000000030") +EXPECTED_VERSION = UUID("0198a412-8000-7000-8000-000000000031") +TERMINAL_VERSION = UUID("0198a412-8000-7000-8000-000000000032") +AUDIT_EVENT = UUID("0198a412-8000-7000-8000-000000000080") +OUTBOX = UUID("0198a412-8000-7000-8000-000000000081") +RECORDED_AT = datetime(2026, 9, 12, 14, 45, tzinfo=timezone.utc) + +PRINCIPAL = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:people-operator-17", + granted_scope_codes=frozenset({"orgmetra.people.write"}), +) + + +def command(**overrides: object) -> EmploymentSeparationCommand: + """Build one deterministic separation command.""" + values: dict[str, object] = { + "tenant_record_id": TENANT, + "person_record_id": PERSON, + "employment_record_id": EMPLOYMENT, + "expected_employment_record_version_id": EXPECTED_VERSION, + "separation_effective_on": date(2026, 10, 1), + "separation_reason_code": "voluntary_resignation", + "evidence_reference": "separation_packet:case-17", + "evidence_version_code": "v1", + "confirmation_reference": "human_confirmation:case-17", + "idempotency_key": "employment-separation-case-17", + "audit_event_record_id": AUDIT_EVENT, + "outbox_delivery_record_id": OUTBOX, + } + values.update(overrides) + return EmploymentSeparationCommand(**values) # type: ignore[arg-type] + + +def policy(*, purpose_code: str = "workforce_admin") -> PurposeBoundAccessPolicy: + """Return the exact policy required by the separation application boundary.""" + return PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="employment-separation-v1", + resource_kind="employment_record", + purpose_code=purpose_code, + operation_code="separate_record", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({"employment_record"}), + ) + + +class RecordingSeparationPort: + """Capture the authorized command without touching persistence.""" + + def __init__(self) -> None: + self.calls: list[tuple[EmploymentSeparationCommand, object]] = [] + + def separate_employment(self, *, command: EmploymentSeparationCommand, authorization: object) -> EmploymentSeparationResult: + self.calls.append((command, authorization)) + return EmploymentSeparationResult( + employment_record_id=command.employment_record_id, + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=RECORDED_AT, + replayed=False, + ) + + +class WrongIdentityPort(RecordingSeparationPort): + """Return a foreign Employment identity to prove result binding fails closed.""" + + def separate_employment(self, *, command: EmploymentSeparationCommand, authorization: object) -> EmploymentSeparationResult: + del command, authorization + return EmploymentSeparationResult( + employment_record_id=UUID("0198a412-8000-7000-8000-000000000099"), + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=RECORDED_AT, + replayed=False, + ) + + +class EmploymentSeparationApplicationTests(unittest.TestCase): + """Prove authorization and typed result binding before exposing separation truth.""" + + def test_authorizes_exact_employment_before_persistence(self) -> None: + port = RecordingSeparationPort() + result = separate_employment_record( + principal=PRINCIPAL, + command=command(), + purpose_code="workforce_admin", + policy=policy(), + separation_port=port, + ) + + self.assertIsInstance(port, EmploymentSeparationPort) + self.assertEqual(result.employment_record_id, EMPLOYMENT) + self.assertEqual(result.separated_employment_record_version_id, TERMINAL_VERSION) + self.assertEqual(result.recorded_at, RECORDED_AT) + self.assertFalse(result.replayed) + recorded_command, authorization = port.calls[0] + self.assertIsNot(recorded_command, command()) + self.assertEqual(authorization.resource_reference, f"employment_record:{EMPLOYMENT.hex}") + self.assertEqual(authorization.operation_code, "separate_record") + self.assertEqual(authorization.purpose_code, "workforce_admin") + + def test_policy_denial_prevents_separation(self) -> None: + port = RecordingSeparationPort() + with self.assertRaises(AuthorizationDeniedError): + separate_employment_record( + principal=PRINCIPAL, + command=command(), + purpose_code="workforce_admin", + policy=policy(purpose_code="benefits_admin"), + separation_port=port, + ) + self.assertEqual(port.calls, []) + + def test_requires_workforce_admin_purpose(self) -> None: + port = RecordingSeparationPort() + with self.assertRaisesRegex(ValueError, "workforce_admin"): + separate_employment_record( + principal=PRINCIPAL, + command=command(), + purpose_code="benefits_admin", + policy=policy(purpose_code="benefits_admin"), + separation_port=port, + ) + self.assertEqual(port.calls, []) + + def test_command_rejects_malformed_high_impact_evidence(self) -> None: + cases = ( + lambda: command(tenant_record_id=UUID(int=0)), + lambda: command(separation_effective_on="2026-10-01"), + lambda: command(separation_reason_code="Voluntary resignation"), + lambda: command(evidence_reference="not-namespaced"), + lambda: command(evidence_version_code="has space"), + lambda: command(confirmation_reference="not-namespaced"), + lambda: command(idempotency_key="short"), + lambda: command(idempotency_key="x" * 201), + ) + for builder in cases: + with self.subTest(builder=builder), self.assertRaises(ValueError): + builder() + + def test_result_rejects_malformed_database_evidence(self) -> None: + cases = ( + lambda: EmploymentSeparationResult( + employment_record_id=UUID(int=0), + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=RECORDED_AT, + replayed=False, + ), + lambda: EmploymentSeparationResult( + employment_record_id=EMPLOYMENT, + separated_employment_record_version_id=UUID(int=0), + recorded_at=RECORDED_AT, + replayed=False, + ), + lambda: EmploymentSeparationResult( + employment_record_id=EMPLOYMENT, + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=datetime(2026, 9, 12, 14, 45), + replayed=False, + ), + lambda: EmploymentSeparationResult( + employment_record_id=EMPLOYMENT, + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=RECORDED_AT, + replayed=1, # type: ignore[arg-type] + ), + ) + for builder in cases: + with self.subTest(builder=builder), self.assertRaises(ValueError): + builder() + + def test_foreign_result_identity_fails_closed(self) -> None: + with self.assertRaises(EmploymentSeparationIntegrityError): + separate_employment_record( + principal=PRINCIPAL, + command=command(), + purpose_code="workforce_admin", + policy=policy(), + separation_port=WrongIdentityPort(), + ) + + def test_requires_typed_command_port_and_result(self) -> None: + with self.assertRaisesRegex(TypeError, "EmploymentSeparationCommand"): + separate_employment_record( + principal=PRINCIPAL, + command=object(), # type: ignore[arg-type] + purpose_code="workforce_admin", + policy=policy(), + separation_port=RecordingSeparationPort(), + ) + with self.assertRaisesRegex(TypeError, "EmploymentSeparationPort"): + separate_employment_record( + principal=PRINCIPAL, + command=command(), + purpose_code="workforce_admin", + policy=policy(), + separation_port=object(), # type: ignore[arg-type] + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From aadb57099cf16188a07d0a29e8e5ef294d2d48b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:44:32 +0900 Subject: [PATCH 154/269] fix(people): add authorized separation application boundary --- .../src/orgmetra_people_api/separation.py | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 services/people-api/src/orgmetra_people_api/separation.py diff --git a/services/people-api/src/orgmetra_people_api/separation.py b/services/people-api/src/orgmetra_people_api/separation.py new file mode 100644 index 000000000..6020c44e2 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/separation.py @@ -0,0 +1,186 @@ +"""Governed application boundary for authoritative Employment separation.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import date, datetime, timezone +import re +from typing import Protocol, runtime_checkable +from uuid import UUID +from zoneinfo import ZoneInfo + +from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy + +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.authorization import authorize_resource_fields +from orgmetra_people_api.mutations import validate_idempotency_key + +_MAX_UUID_INT = (1 << 128) - 1 +_REFERENCE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$") +_VERSION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_REASON_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") +_EMPLOYMENT_FIELDS = frozenset({"employment_record"}) + + +class EmploymentSeparationIntegrityError(RuntimeError): + """Indicate that separation evidence cannot be trusted as the requested result.""" + + +def _operational_uuid(field_name: str, value: object) -> UUID: + """Validate one operational UUID and detach any retained identity alias.""" + if type(value) is not UUID: + raise ValueError(f"{field_name} must be an operational UUID.") + identity = value.int + if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): + raise ValueError(f"{field_name} must be an operational UUID.") + return UUID(int=identity) + + +def _namespaced_reference(field_name: str, value: object) -> str: + """Validate one bounded opaque namespaced reference.""" + if type(value) is not str or _REFERENCE_PATTERN.fullmatch(value) is None: + raise ValueError(f"{field_name} must be a namespaced opaque reference.") + return value + + +def _version_code(value: object) -> str: + """Validate one whitespace-free evidence version token.""" + if type(value) is not str or _VERSION_PATTERN.fullmatch(value) is None: + raise ValueError("evidence_version_code must be a whitespace-free version token.") + return value + + +def _aware_datetime(field_name: str, value: object) -> datetime: + """Validate database-owned time without retaining executable timezone aliases.""" + if type(value) is not datetime or value.tzinfo is None: + raise ValueError(f"{field_name} must be an aware datetime.") + if type(value.tzinfo) not in (timezone, ZoneInfo) or value.utcoffset() is None: + raise ValueError(f"{field_name} must use a standard aware timezone provider.") + return value + + +@dataclass(frozen=True, slots=True) +class EmploymentSeparationCommand: + """High-impact evidence required to separate one exact current Employment.""" + + tenant_record_id: UUID + person_record_id: UUID + employment_record_id: UUID + expected_employment_record_version_id: UUID + separation_effective_on: date + separation_reason_code: str + evidence_reference: str + evidence_version_code: str + confirmation_reference: str + idempotency_key: str + audit_event_record_id: UUID + outbox_delivery_record_id: UUID + + def __post_init__(self) -> None: + """Fail closed and detach operational identities before authorization.""" + for field_name in ( + "tenant_record_id", + "person_record_id", + "employment_record_id", + "expected_employment_record_version_id", + "audit_event_record_id", + "outbox_delivery_record_id", + ): + object.__setattr__(self, field_name, _operational_uuid(field_name, getattr(self, field_name))) + if type(self.separation_effective_on) is not date: + raise ValueError("separation_effective_on must be a business date.") + if type(self.separation_reason_code) is not str or _REASON_PATTERN.fullmatch(self.separation_reason_code) is None: + raise ValueError("separation_reason_code must be a lower snake_case code.") + _namespaced_reference("evidence_reference", self.evidence_reference) + _version_code(self.evidence_version_code) + _namespaced_reference("confirmation_reference", self.confirmation_reference) + validate_idempotency_key(self.idempotency_key) + + +@dataclass(frozen=True, slots=True) +class EmploymentSeparationResult: + """Database-owned identity and recorded-time evidence for one separation.""" + + employment_record_id: UUID + separated_employment_record_version_id: UUID + recorded_at: datetime + replayed: bool + + def __post_init__(self) -> None: + """Validate and detach persistence evidence before returning it to callers.""" + object.__setattr__( + self, + "employment_record_id", + _operational_uuid("employment_record_id", self.employment_record_id), + ) + object.__setattr__( + self, + "separated_employment_record_version_id", + _operational_uuid( + "separated_employment_record_version_id", + self.separated_employment_record_version_id, + ), + ) + _aware_datetime("recorded_at", self.recorded_at) + if type(self.replayed) is not bool: + raise ValueError("replayed must be a bool.") + + +@runtime_checkable +class EmploymentSeparationPort(Protocol): + """Persist one authorized Employment separation in an Orgmetra-owned transaction.""" + + def separate_employment( + self, + *, + command: EmploymentSeparationCommand, + authorization: AuthorizationDecision, + ) -> EmploymentSeparationResult: + """Return the first committed terminal Employment version or its replay.""" + + +def _require_port(separation_port: object) -> EmploymentSeparationPort: + """Reject dependencies that do not expose the governed separation operation.""" + if not isinstance(separation_port, EmploymentSeparationPort): + raise TypeError("separation_port must implement EmploymentSeparationPort") + return separation_port + + +def separate_employment_record( + *, + principal: AuthenticatedPrincipal, + command: EmploymentSeparationCommand, + purpose_code: str, + policy: PurposeBoundAccessPolicy, + separation_port: EmploymentSeparationPort, +) -> EmploymentSeparationResult: + """Authorize one exact Employment separation before crossing persistence.""" + if type(command) is not EmploymentSeparationCommand: + raise TypeError("command must be an EmploymentSeparationCommand") + if type(purpose_code) is not str or purpose_code != "workforce_admin": + raise ValueError("Employment separation requires workforce_admin purpose.") + + detached_command = replace(command) + expected_employment_record_id = UUID(int=detached_command.employment_record_id.int) + port = _require_port(separation_port) + authorization = authorize_resource_fields( + principal=principal, + tenant_record_id=detached_command.tenant_record_id, + resource_tenant_record_id=detached_command.tenant_record_id, + resource_reference=f"employment_record:{detached_command.employment_record_id.hex}", + purpose_code=purpose_code, + operation_code="separate_record", + resource_kind="employment_record", + requested_fields=_EMPLOYMENT_FIELDS, + policy=policy, + ) + result = port.separate_employment( + command=replace(detached_command), + authorization=authorization, + ) + if type(result) is not EmploymentSeparationResult: + raise TypeError("separation_port must return EmploymentSeparationResult") + detached_result = replace(result) + if detached_result.employment_record_id != expected_employment_record_id: + raise EmploymentSeparationIntegrityError("separation result identity does not match command") + return detached_result From efa986256c5d6b74b0fded5a6c3f2555ef38406c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:45:03 +0900 Subject: [PATCH 155/269] test(people): harden separation application evidence --- .../test_employment_separation_application.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_employment_separation_application.py b/services/people-api/tests/test_employment_separation_application.py index 7bf8082f7..9eb2486e2 100644 --- a/services/people-api/tests/test_employment_separation_application.py +++ b/services/people-api/tests/test_employment_separation_application.py @@ -94,14 +94,23 @@ def separate_employment(self, *, command: EmploymentSeparationCommand, authoriza ) +class InvalidResultPort(RecordingSeparationPort): + """Satisfy the protocol while returning an untrusted implementation result.""" + + def separate_employment(self, *, command: EmploymentSeparationCommand, authorization: object) -> object: + del command, authorization + return object() + + class EmploymentSeparationApplicationTests(unittest.TestCase): """Prove authorization and typed result binding before exposing separation truth.""" def test_authorizes_exact_employment_before_persistence(self) -> None: port = RecordingSeparationPort() + submitted_command = command() result = separate_employment_record( principal=PRINCIPAL, - command=command(), + command=submitted_command, purpose_code="workforce_admin", policy=policy(), separation_port=port, @@ -113,7 +122,7 @@ def test_authorizes_exact_employment_before_persistence(self) -> None: self.assertEqual(result.recorded_at, RECORDED_AT) self.assertFalse(result.replayed) recorded_command, authorization = port.calls[0] - self.assertIsNot(recorded_command, command()) + self.assertIsNot(recorded_command, submitted_command) self.assertEqual(authorization.resource_reference, f"employment_record:{EMPLOYMENT.hex}") self.assertEqual(authorization.operation_code, "separate_record") self.assertEqual(authorization.purpose_code, "workforce_admin") @@ -215,6 +224,14 @@ def test_requires_typed_command_port_and_result(self) -> None: policy=policy(), separation_port=object(), # type: ignore[arg-type] ) + with self.assertRaisesRegex(TypeError, "EmploymentSeparationResult"): + separate_employment_record( + principal=PRINCIPAL, + command=command(), + purpose_code="workforce_admin", + policy=policy(), + separation_port=InvalidResultPort(), # type: ignore[arg-type] + ) if __name__ == "__main__": # pragma: no cover From a3d3700877c6ed2a43bd1137910d651a18e1b12a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:45:53 +0900 Subject: [PATCH 156/269] test(people): require PostgreSQL separation adapter --- .../test_postgres_employment_separation.py | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 services/people-api/tests/test_postgres_employment_separation.py diff --git a/services/people-api/tests/test_postgres_employment_separation.py b/services/people-api/tests/test_postgres_employment_separation.py new file mode 100644 index 000000000..42971e062 --- /dev/null +++ b/services/people-api/tests/test_postgres_employment_separation.py @@ -0,0 +1,227 @@ +"""Unit contracts for the PostgreSQL Employment separation adapter.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from datetime import date, datetime, timezone +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.mutations import PeopleMutationNotFound +from orgmetra_people_api.postgres_separation import PostgresEmploymentSeparationPort +from orgmetra_people_api.separation import ( + EmploymentSeparationCommand, + EmploymentSeparationIntegrityError, +) + +TENANT = UUID("0198a412-8000-7000-8000-000000000001") +PERSON = UUID("0198a412-8000-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-8000-7000-8000-000000000030") +EXPECTED_VERSION = UUID("0198a412-8000-7000-8000-000000000031") +TERMINAL_VERSION = UUID("0198a412-8000-7000-8000-000000000032") +AUDIT_EVENT = UUID("0198a412-8000-7000-8000-000000000080") +OUTBOX = UUID("0198a412-8000-7000-8000-000000000081") +RECORDED_AT = datetime(2026, 9, 12, 15, 0, tzinfo=timezone.utc) + + +def command() -> EmploymentSeparationCommand: + """Build one deterministic adapter command.""" + return EmploymentSeparationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + expected_employment_record_version_id=EXPECTED_VERSION, + separation_effective_on=date(2026, 10, 1), + separation_reason_code="voluntary_resignation", + evidence_reference="separation_packet:adapter-case", + evidence_version_code="v1", + confirmation_reference="human_confirmation:adapter-case", + idempotency_key="employment-separation-adapter-case", + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + ) + + +def authorization(**overrides: object) -> AuthorizationDecision: + """Build exact allow evidence for the requested separation.""" + values: dict[str, object] = { + "allowed": True, + "tenant_record_id": TENANT, + "actor_reference": "keyverse_subject:people-operator-17", + "resource_reference": f"employment_record:{EMPLOYMENT.hex}", + "policy_version_code": "employment-separation-v1", + "purpose_code": "workforce_admin", + "operation_code": "separate_record", + "resource_kind": "employment_record", + "requested_fields": frozenset({"employment_record"}), + "authorized_fields": frozenset({"employment_record"}), + "reason_code": "allowed", + "next_action": "Continue with only the authorized fields.", + } + values.update(overrides) + return AuthorizationDecision(**values) # type: ignore[arg-type] + + +class DatabaseFailure(RuntimeError): + """Provide only the SQLSTATE surface used by the adapter boundary.""" + + def __init__(self, sqlstate: str) -> None: + super().__init__(sqlstate) + self.sqlstate = sqlstate + + +class FakeCursor(AbstractContextManager["FakeCursor"]): + """Record SQL calls and supply one fixed database result or failure.""" + + def __init__(self, *, row: tuple[object, ...] | None = None, failure: Exception | None = None) -> None: + self.row = row + self.failure = failure + self.calls: list[tuple[str, object | None]] = [] + + def __enter__(self) -> "FakeCursor": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + return None + + def execute(self, sql: str, parameters: object | None = None) -> None: + self.calls.append((sql, parameters)) + if "separate_employment_record_once" in sql and self.failure is not None: + raise self.failure + + def fetchmany(self, size: int) -> list[tuple[object, ...]]: + if size != 2 or self.row is None: + return [] + return [self.row] + + +class FakeConnection(AbstractContextManager["FakeConnection"]): + """Expose one cursor through the DB-API context-manager shape.""" + + def __init__(self, cursor: FakeCursor) -> None: + self._cursor = cursor + + def __enter__(self) -> "FakeConnection": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + return None + + def cursor(self) -> FakeCursor: + return self._cursor + + +class ConnectionFactory: + """Return the captured connection and count invocations.""" + + def __init__(self, connection: FakeConnection) -> None: + self.connection = connection + self.calls = 0 + + def __call__(self) -> FakeConnection: + self.calls += 1 + return self.connection + + +class PostgresEmploymentSeparationTests(unittest.TestCase): + """Prove checked authorization, tenant binding and typed DB evidence.""" + + def test_calls_governed_function_in_one_tenant_bound_transaction(self) -> None: + cursor = FakeCursor(row=(EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, False)) + factory = ConnectionFactory(FakeConnection(cursor)) + port = PostgresEmploymentSeparationPort(factory) + + result = port.separate_employment(command=command(), authorization=authorization()) + + self.assertEqual(factory.calls, 1) + self.assertEqual(result.employment_record_id, EMPLOYMENT) + self.assertEqual(result.separated_employment_record_version_id, TERMINAL_VERSION) + self.assertEqual(result.recorded_at, RECORDED_AT) + self.assertFalse(result.replayed) + self.assertEqual(cursor.calls[0][0], "SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ WRITE") + self.assertEqual(cursor.calls[1][1], (str(TENANT),)) + function_sql, function_parameters = cursor.calls[2] + self.assertIn("public.separate_employment_record_once", function_sql) + self.assertEqual( + function_parameters, + ( + TENANT, + PERSON, + EMPLOYMENT, + EXPECTED_VERSION, + date(2026, 10, 1), + "voluntary_resignation", + "separation_packet:adapter-case", + "v1", + "keyverse_subject:people-operator-17", + "workforce_admin", + "human_confirmation:adapter-case", + "employment-separation-adapter-case", + AUDIT_EVENT, + OUTBOX, + ), + ) + + def test_rejects_authorization_that_does_not_match_exact_operation(self) -> None: + port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(FakeCursor()))) + cases = ( + authorization(allowed=False, authorized_fields=frozenset()), + authorization(tenant_record_id=UUID("0198a412-8000-7000-8000-000000000099")), + authorization(resource_reference="employment_record:0198a412800070008000000000000099"), + authorization(purpose_code="benefits_admin"), + authorization(operation_code="create_record"), + authorization(resource_kind="assignment_record"), + authorization(requested_fields=frozenset({"assignment_record")), + authorization(authorized_fields=frozenset({"assignment_record")), + ) + for decision in cases: + with self.subTest(decision=decision), self.assertRaises(EmploymentSeparationIntegrityError): + port.separate_employment(command=command(), authorization=decision) + + def test_rejects_malformed_database_result(self) -> None: + rows = ( + None, + (EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT), + (EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, False, "extra"), + (UUID(int=0), TERMINAL_VERSION, RECORDED_AT, False), + (EMPLOYMENT, TERMINAL_VERSION, datetime(2026, 9, 12, 15, 0), False), + (EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, 1), + ) + for row in rows: + with self.subTest(row=row), self.assertRaises((EmploymentSeparationIntegrityError, ValueError)): + port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(FakeCursor(row=row)))) + port.separate_employment(command=command(), authorization=authorization()) + + def test_maps_governed_conflicts_but_not_permission_failures(self) -> None: + cases = ( + ("23503", PeopleMutationNotFound), + ("40001", EmploymentSeparationIntegrityError), + ("23505", EmploymentSeparationIntegrityError), + ("55000", EmploymentSeparationIntegrityError), + ("22023", EmploymentSeparationIntegrityError), + ) + for sqlstate, expected in cases: + with self.subTest(sqlstate=sqlstate), self.assertRaises(expected): + port = PostgresEmploymentSeparationPort( + ConnectionFactory(FakeConnection(FakeCursor(failure=DatabaseFailure(sqlstate)))) + ) + port.separate_employment(command=command(), authorization=authorization()) + + permission_failure = DatabaseFailure("42501") + with self.assertRaises(DatabaseFailure): + port = PostgresEmploymentSeparationPort( + ConnectionFactory(FakeConnection(FakeCursor(failure=permission_failure))) + ) + port.separate_employment(command=command(), authorization=authorization()) + + def test_structurally_binds_connection_factory(self) -> None: + factory = ConnectionFactory(FakeConnection(FakeCursor(row=(EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, True)))) + port = PostgresEmploymentSeparationPort(factory) + self.assertIs(tuple.__getitem__(port, 0), factory) + with self.assertRaises(AttributeError): + port.connection_factory = object() # type: ignore[attr-defined] + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 443a4875768e342dad1ffbeeb896ec3da8a06ab4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:46:13 +0900 Subject: [PATCH 157/269] fix(people): add PostgreSQL separation adapter --- .../postgres_separation.py | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 services/people-api/src/orgmetra_people_api/postgres_separation.py diff --git a/services/people-api/src/orgmetra_people_api/postgres_separation.py b/services/people-api/src/orgmetra_people_api/postgres_separation.py new file mode 100644 index 000000000..ca6418895 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/postgres_separation.py @@ -0,0 +1,151 @@ +"""PostgreSQL adapter for the governed Employment separation transaction.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from dataclasses import replace +from typing import Any, Callable + +from orgmetra_keyverse_adapter import AuthorizationDecision + +from orgmetra_people_api.mutations import PeopleMutationNotFound +from orgmetra_people_api.separation import ( + EmploymentSeparationCommand, + EmploymentSeparationIntegrityError, + EmploymentSeparationResult, +) + +PostgresConnectionFactory = Callable[[], AbstractContextManager[Any]] + +_READ_WRITE_SQL = "SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ WRITE" +_TENANT_CONTEXT_SQL = "SELECT pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" +_SEPARATE_EMPLOYMENT_SQL = """ +SELECT + employment_record_id, + separated_employment_record_version_id, + recorded_at, + replayed +FROM public.separate_employment_record_once( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s +) +""".strip() +_EMPLOYMENT_FIELDS = frozenset({"employment_record"}) + + +def _require_authorization( + *, + authorization: object, + command: EmploymentSeparationCommand, +) -> AuthorizationDecision: + """Require exact allow evidence for the governed separation operation.""" + if type(authorization) is not AuthorizationDecision: + raise EmploymentSeparationIntegrityError("Employment separation requires typed authorization evidence") + if ( + not authorization.allowed + or authorization.tenant_record_id != command.tenant_record_id + or authorization.resource_reference != f"employment_record:{command.employment_record_id.hex}" + or authorization.purpose_code != "workforce_admin" + or authorization.operation_code != "separate_record" + or authorization.resource_kind != "employment_record" + or authorization.requested_fields != _EMPLOYMENT_FIELDS + or authorization.authorized_fields != _EMPLOYMENT_FIELDS + ): + raise EmploymentSeparationIntegrityError("Employment separation authorization does not match the exact record") + return authorization + + +def _one_result_row(cursor: Any) -> tuple[object, object, object, object]: + """Detach exactly one built-in database row before validating result evidence.""" + rows = cursor.fetchmany(2) + if type(rows) not in (list, tuple) or len(rows) != 1: + raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") + row = rows[0] + if type(row) not in (list, tuple) or len(row) != 4: + raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") + return row[0], row[1], row[2], row[3] + + +def _translate_database_error(error: Exception) -> None: + """Translate only reviewed business SQLSTATEs; permission failures remain operational errors.""" + sqlstate = getattr(error, "sqlstate", None) + if sqlstate == "23503": + raise PeopleMutationNotFound("Employment separation target was not found") from error + if sqlstate in {"40001", "23505", "55000", "22023"}: + raise EmploymentSeparationIntegrityError("Employment separation conflicts with authoritative state") from error + raise error + + +class PostgresEmploymentSeparationPort(tuple): + """Invoke only the governed separation function inside one tenant-bound transaction. + + The database login is expected to receive the released + ``orgmetra_employment_separation_executor`` capability operationally. This + adapter never assumes the privileged function-owner role and never issues + direct People/audit/outbox DML. + """ + + __slots__ = () + + def __new__( + cls, + connection_factory: PostgresConnectionFactory, + ) -> PostgresEmploymentSeparationPort: + """Validate and structurally bind the executable database capability.""" + if not callable(connection_factory): + raise TypeError("connection_factory must be callable") + return tuple.__new__(cls, (connection_factory,)) + + def separate_employment( + self, + *, + command: EmploymentSeparationCommand, + authorization: AuthorizationDecision, + ) -> EmploymentSeparationResult: + """Persist or replay one authorized Employment separation.""" + if type(command) is not EmploymentSeparationCommand: + raise TypeError("command must be an EmploymentSeparationCommand") + detached_command = replace(command) + decision = _require_authorization(authorization=authorization, command=detached_command) + connection_factory = tuple.__getitem__(self, 0) + + try: + with connection_factory() as connection: + with connection.cursor() as cursor: + cursor.execute(_READ_WRITE_SQL) + cursor.execute(_TENANT_CONTEXT_SQL, (str(detached_command.tenant_record_id),)) + cursor.execute( + _SEPARATE_EMPLOYMENT_SQL, + ( + detached_command.tenant_record_id, + detached_command.person_record_id, + detached_command.employment_record_id, + detached_command.expected_employment_record_version_id, + detached_command.separation_effective_on, + detached_command.separation_reason_code, + detached_command.evidence_reference, + detached_command.evidence_version_code, + decision.actor_reference, + decision.purpose_code, + detached_command.confirmation_reference, + detached_command.idempotency_key, + detached_command.audit_event_record_id, + detached_command.outbox_delivery_record_id, + ), + ) + row = _one_result_row(cursor) + except Exception as error: + _translate_database_error(error) + raise AssertionError("unreachable") from error + + try: + result = EmploymentSeparationResult( + employment_record_id=row[0], # type: ignore[arg-type] + separated_employment_record_version_id=row[1], # type: ignore[arg-type] + recorded_at=row[2], # type: ignore[arg-type] + replayed=row[3], # type: ignore[arg-type] + ) + except (TypeError, ValueError) as error: + raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") from error + if result.employment_record_id != detached_command.employment_record_id: + raise EmploymentSeparationIntegrityError("Employment separation database identity does not match command") + return result From f08b7126b972c0930df4c67763cf1d6ec6332bc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:46:36 +0900 Subject: [PATCH 158/269] fix(people): export separation application port --- .../src/orgmetra_people_api/__init__.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index b043bed33..0a83decf7 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -1,4 +1,4 @@ -"""Request-edge, governed read, confirmed-hire, and People mutation contracts.""" +"""Request-edge, governed read, hire, separation, and People mutation contracts.""" from orgmetra_people_api.auth import ( AuthenticatedPrincipal, @@ -43,6 +43,14 @@ from orgmetra_people_api.postgres import PostgresPeopleReadPort from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from orgmetra_people_api.postgres_separation import PostgresEmploymentSeparationPort +from orgmetra_people_api.separation import ( + EmploymentSeparationCommand, + EmploymentSeparationIntegrityError, + EmploymentSeparationPort, + EmploymentSeparationResult, + separate_employment_record, +) __all__ = [ "AuthenticatedPrincipal", @@ -67,10 +75,15 @@ "PostgresHireAcceptancePort", "PostgresPeopleMutationPort", "PostgresPeopleReadPort", + "PostgresEmploymentSeparationPort", "AssignmentMutationCommand", "AssignmentMutationResult", "EmploymentMutationCommand", "EmploymentMutationResult", + "EmploymentSeparationCommand", + "EmploymentSeparationIntegrityError", + "EmploymentSeparationPort", + "EmploymentSeparationResult", "TokenAuthenticator", "WorkerPeopleRecord", "accept_confirmed_hire", @@ -80,4 +93,5 @@ "create_position_record", "extract_bearer_token", "read_worker_people_record", + "separate_employment_record", ] From 0254da5b504af861adf4343ad98ad89871c931ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:47:24 +0900 Subject: [PATCH 159/269] fix(people): make separation error translation total --- .../src/orgmetra_people_api/postgres_separation.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_separation.py b/services/people-api/src/orgmetra_people_api/postgres_separation.py index ca6418895..bb99bce9d 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_separation.py +++ b/services/people-api/src/orgmetra_people_api/postgres_separation.py @@ -4,7 +4,7 @@ from contextlib import AbstractContextManager from dataclasses import replace -from typing import Any, Callable +from typing import Any, Callable, NoReturn from orgmetra_keyverse_adapter import AuthorizationDecision @@ -65,7 +65,7 @@ def _one_result_row(cursor: Any) -> tuple[object, object, object, object]: return row[0], row[1], row[2], row[3] -def _translate_database_error(error: Exception) -> None: +def _translate_database_error(error: Exception) -> NoReturn: """Translate only reviewed business SQLSTATEs; permission failures remain operational errors.""" sqlstate = getattr(error, "sqlstate", None) if sqlstate == "23503": @@ -135,7 +135,6 @@ def separate_employment( row = _one_result_row(cursor) except Exception as error: _translate_database_error(error) - raise AssertionError("unreachable") from error try: result = EmploymentSeparationResult( From cb7b61b0f329a04bb53f64aab343dc8722e28e0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:48:10 +0900 Subject: [PATCH 160/269] test(people): cover separation adapter trust boundary --- .../test_postgres_employment_separation.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/services/people-api/tests/test_postgres_employment_separation.py b/services/people-api/tests/test_postgres_employment_separation.py index 42971e062..f65e89efb 100644 --- a/services/people-api/tests/test_postgres_employment_separation.py +++ b/services/people-api/tests/test_postgres_employment_separation.py @@ -74,7 +74,7 @@ def __init__(self, sqlstate: str) -> None: class FakeCursor(AbstractContextManager["FakeCursor"]): """Record SQL calls and supply one fixed database result or failure.""" - def __init__(self, *, row: tuple[object, ...] | None = None, failure: Exception | None = None) -> None: + def __init__(self, *, row: object | None = None, failure: Exception | None = None) -> None: self.row = row self.failure = failure self.calls: list[tuple[str, object | None]] = [] @@ -90,12 +90,20 @@ def execute(self, sql: str, parameters: object | None = None) -> None: if "separate_employment_record_once" in sql and self.failure is not None: raise self.failure - def fetchmany(self, size: int) -> list[tuple[object, ...]]: + def fetchmany(self, size: int) -> object: if size != 2 or self.row is None: return [] return [self.row] +class InvalidBatchCursor(FakeCursor): + """Return a non-container fetch batch to exercise the DB trust boundary.""" + + def fetchmany(self, size: int) -> object: + del size + return object() + + class FakeConnection(AbstractContextManager["FakeConnection"]): """Expose one cursor through the DB-API context-manager shape.""" @@ -165,7 +173,8 @@ def test_calls_governed_function_in_one_tenant_bound_transaction(self) -> None: def test_rejects_authorization_that_does_not_match_exact_operation(self) -> None: port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(FakeCursor()))) - cases = ( + cases: tuple[object, ...] = ( + object(), authorization(allowed=False, authorized_fields=frozenset()), authorization(tenant_record_id=UUID("0198a412-8000-7000-8000-000000000099")), authorization(resource_reference="employment_record:0198a412800070008000000000000099"), @@ -177,22 +186,28 @@ def test_rejects_authorization_that_does_not_match_exact_operation(self) -> None ) for decision in cases: with self.subTest(decision=decision), self.assertRaises(EmploymentSeparationIntegrityError): - port.separate_employment(command=command(), authorization=decision) + port.separate_employment(command=command(), authorization=decision) # type: ignore[arg-type] def test_rejects_malformed_database_result(self) -> None: rows = ( None, + "not-a-row", (EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT), (EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, False, "extra"), (UUID(int=0), TERMINAL_VERSION, RECORDED_AT, False), (EMPLOYMENT, TERMINAL_VERSION, datetime(2026, 9, 12, 15, 0), False), (EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, 1), + (UUID("0198a412-8000-7000-8000-000000000099"), TERMINAL_VERSION, RECORDED_AT, False), ) for row in rows: - with self.subTest(row=row), self.assertRaises((EmploymentSeparationIntegrityError, ValueError)): + with self.subTest(row=row), self.assertRaises(EmploymentSeparationIntegrityError): port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(FakeCursor(row=row)))) port.separate_employment(command=command(), authorization=authorization()) + with self.assertRaises(EmploymentSeparationIntegrityError): + port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(InvalidBatchCursor()))) + port.separate_employment(command=command(), authorization=authorization()) + def test_maps_governed_conflicts_but_not_permission_failures(self) -> None: cases = ( ("23503", PeopleMutationNotFound), @@ -215,6 +230,14 @@ def test_maps_governed_conflicts_but_not_permission_failures(self) -> None: ) port.separate_employment(command=command(), authorization=authorization()) + def test_requires_typed_command_and_callable_factory(self) -> None: + with self.assertRaisesRegex(TypeError, "connection_factory"): + PostgresEmploymentSeparationPort(object()) # type: ignore[arg-type] + + port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(FakeCursor()))) + with self.assertRaisesRegex(TypeError, "EmploymentSeparationCommand"): + port.separate_employment(command=object(), authorization=authorization()) # type: ignore[arg-type] + def test_structurally_binds_connection_factory(self) -> None: factory = ConnectionFactory(FakeConnection(FakeCursor(row=(EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, True)))) port = PostgresEmploymentSeparationPort(factory) From 6a734db0e210ebbb0861c081ae4e361911f08eb7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:48:46 +0900 Subject: [PATCH 161/269] fix(people): make separation validation branches explicit --- .../src/orgmetra_people_api/separation.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/separation.py b/services/people-api/src/orgmetra_people_api/separation.py index 6020c44e2..8e20d045a 100644 --- a/services/people-api/src/orgmetra_people_api/separation.py +++ b/services/people-api/src/orgmetra_people_api/separation.py @@ -30,31 +30,36 @@ def _operational_uuid(field_name: str, value: object) -> UUID: """Validate one operational UUID and detach any retained identity alias.""" if type(value) is not UUID: raise ValueError(f"{field_name} must be an operational UUID.") - identity = value.int - if type(identity) is not int or not (0 < identity < _MAX_UUID_INT): + if not (0 < value.int < _MAX_UUID_INT): raise ValueError(f"{field_name} must be an operational UUID.") - return UUID(int=identity) + return UUID(int=value.int) def _namespaced_reference(field_name: str, value: object) -> str: """Validate one bounded opaque namespaced reference.""" - if type(value) is not str or _REFERENCE_PATTERN.fullmatch(value) is None: + if type(value) is not str: + raise ValueError(f"{field_name} must be a namespaced opaque reference.") + if _REFERENCE_PATTERN.fullmatch(value) is None: raise ValueError(f"{field_name} must be a namespaced opaque reference.") return value def _version_code(value: object) -> str: """Validate one whitespace-free evidence version token.""" - if type(value) is not str or _VERSION_PATTERN.fullmatch(value) is None: + if type(value) is not str: + raise ValueError("evidence_version_code must be a whitespace-free version token.") + if _VERSION_PATTERN.fullmatch(value) is None: raise ValueError("evidence_version_code must be a whitespace-free version token.") return value def _aware_datetime(field_name: str, value: object) -> datetime: - """Validate database-owned time without retaining executable timezone aliases.""" - if type(value) is not datetime or value.tzinfo is None: + """Validate database-owned time without accepting executable custom timezone objects.""" + if type(value) is not datetime: + raise ValueError(f"{field_name} must be an aware datetime.") + if value.tzinfo is None: raise ValueError(f"{field_name} must be an aware datetime.") - if type(value.tzinfo) not in (timezone, ZoneInfo) or value.utcoffset() is None: + if type(value.tzinfo) not in (timezone, ZoneInfo): raise ValueError(f"{field_name} must use a standard aware timezone provider.") return value @@ -89,7 +94,9 @@ def __post_init__(self) -> None: object.__setattr__(self, field_name, _operational_uuid(field_name, getattr(self, field_name))) if type(self.separation_effective_on) is not date: raise ValueError("separation_effective_on must be a business date.") - if type(self.separation_reason_code) is not str or _REASON_PATTERN.fullmatch(self.separation_reason_code) is None: + if type(self.separation_reason_code) is not str: + raise ValueError("separation_reason_code must be a lower snake_case code.") + if _REASON_PATTERN.fullmatch(self.separation_reason_code) is None: raise ValueError("separation_reason_code must be a lower snake_case code.") _namespaced_reference("evidence_reference", self.evidence_reference) _version_code(self.evidence_version_code) @@ -157,7 +164,9 @@ def separate_employment_record( """Authorize one exact Employment separation before crossing persistence.""" if type(command) is not EmploymentSeparationCommand: raise TypeError("command must be an EmploymentSeparationCommand") - if type(purpose_code) is not str or purpose_code != "workforce_admin": + if type(purpose_code) is not str: + raise ValueError("Employment separation requires workforce_admin purpose.") + if purpose_code != "workforce_admin": raise ValueError("Employment separation requires workforce_admin purpose.") detached_command = replace(command) From 6c49abd7292c7a5490d784b6ead9d2192bc19322 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:49:20 +0900 Subject: [PATCH 162/269] test(people): cover separation validation boundary --- .../test_employment_separation_application.py | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/services/people-api/tests/test_employment_separation_application.py b/services/people-api/tests/test_employment_separation_application.py index 9eb2486e2..d2210eb21 100644 --- a/services/people-api/tests/test_employment_separation_application.py +++ b/services/people-api/tests/test_employment_separation_application.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone, tzinfo import unittest from uuid import UUID @@ -65,6 +65,18 @@ def policy(*, purpose_code: str = "workforce_admin") -> PurposeBoundAccessPolicy ) +class UnsafeTimezone(tzinfo): + """Behave like UTC while remaining executable caller-defined timezone code.""" + + def utcoffset(self, dt: datetime | None) -> timedelta: + del dt + return timedelta(0) + + def dst(self, dt: datetime | None) -> timedelta: + del dt + return timedelta(0) + + class RecordingSeparationPort: """Capture the authorized command without touching persistence.""" @@ -140,24 +152,32 @@ def test_policy_denial_prevents_separation(self) -> None: self.assertEqual(port.calls, []) def test_requires_workforce_admin_purpose(self) -> None: - port = RecordingSeparationPort() - with self.assertRaisesRegex(ValueError, "workforce_admin"): - separate_employment_record( - principal=PRINCIPAL, - command=command(), - purpose_code="benefits_admin", - policy=policy(purpose_code="benefits_admin"), - separation_port=port, - ) - self.assertEqual(port.calls, []) + for purpose_code in ("benefits_admin", 17): + with self.subTest(purpose_code=purpose_code): + port = RecordingSeparationPort() + with self.assertRaisesRegex(ValueError, "workforce_admin"): + separate_employment_record( + principal=PRINCIPAL, + command=command(), + purpose_code=purpose_code, # type: ignore[arg-type] + policy=policy(purpose_code="benefits_admin"), + separation_port=port, + ) + self.assertEqual(port.calls, []) def test_command_rejects_malformed_high_impact_evidence(self) -> None: cases = ( + lambda: command(tenant_record_id="not-a-uuid"), lambda: command(tenant_record_id=UUID(int=0)), + lambda: command(tenant_record_id=UUID(int=(1 << 128) - 1)), lambda: command(separation_effective_on="2026-10-01"), + lambda: command(separation_reason_code=17), lambda: command(separation_reason_code="Voluntary resignation"), + lambda: command(evidence_reference=17), lambda: command(evidence_reference="not-namespaced"), + lambda: command(evidence_version_code=17), lambda: command(evidence_version_code="has space"), + lambda: command(confirmation_reference=17), lambda: command(confirmation_reference="not-namespaced"), lambda: command(idempotency_key="short"), lambda: command(idempotency_key="x" * 201), @@ -168,6 +188,12 @@ def test_command_rejects_malformed_high_impact_evidence(self) -> None: def test_result_rejects_malformed_database_evidence(self) -> None: cases = ( + lambda: EmploymentSeparationResult( + employment_record_id="not-a-uuid", # type: ignore[arg-type] + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=RECORDED_AT, + replayed=False, + ), lambda: EmploymentSeparationResult( employment_record_id=UUID(int=0), separated_employment_record_version_id=TERMINAL_VERSION, @@ -180,12 +206,24 @@ def test_result_rejects_malformed_database_evidence(self) -> None: recorded_at=RECORDED_AT, replayed=False, ), + lambda: EmploymentSeparationResult( + employment_record_id=EMPLOYMENT, + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at="2026-09-12T14:45:00Z", # type: ignore[arg-type] + replayed=False, + ), lambda: EmploymentSeparationResult( employment_record_id=EMPLOYMENT, separated_employment_record_version_id=TERMINAL_VERSION, recorded_at=datetime(2026, 9, 12, 14, 45), replayed=False, ), + lambda: EmploymentSeparationResult( + employment_record_id=EMPLOYMENT, + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=datetime(2026, 9, 12, 14, 45, tzinfo=UnsafeTimezone()), + replayed=False, + ), lambda: EmploymentSeparationResult( employment_record_id=EMPLOYMENT, separated_employment_record_version_id=TERMINAL_VERSION, From 320fb01aa5e2ce043937ea8d249d813884396119 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 23:50:26 +0900 Subject: [PATCH 163/269] docs(people): trace separation application boundary --- docs/traceability/employment-separation.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index f827cb88d..fc9b201dd 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -14,17 +14,22 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Database-owned recorded time | #314 | one post-lock `clock_timestamp()` closes prior recorded history, opens replacement versions, stamps separation provenance and is serialized into the audit event `time` | implemented_on_active_pr | | High-impact governance evidence | ADR 0006 + ADR 0008 + ADR 0015 | controlled reason, evidence reference/version, actor, `workforce_admin` purpose and human confirmation are mandatory; audit/outbox is persisted in the same transaction | implemented_on_active_pr | | Retry safety | People mutation idempotency contract | exact tenant+route+idempotency-key advisory lock; same semantic command replays first terminal version/timestamp; changed command under same key fails | implemented_on_active_pr | +| Purpose-bound application authorization | Keyverse adapter + ADR 0015 | `separation.py` authorizes the exact Employment, tenant, `workforce_admin` purpose, `separate_record` operation and `orgmetra.people.write` scope before the persistence port; command and result identities are detached and rebound | implemented_on_active_pr | +| Governed persistence adapter | ADR 0015 | `PostgresEmploymentSeparationPort` starts one READ COMMITTED read/write transaction, binds tenant context, invokes only `separate_employment_record_once(...)`, validates one typed DB result, maps reviewed domain SQLSTATEs, and leaves permission failures operational | implemented_on_active_pr | | Deny-default SQL capability | ADR 0015 | migrations `0015`/`0016` revoke PUBLIC, create separate `NOLOGIN`/`NOBYPASSRLS` owner and executor roles, move the function to the owner as SECURITY DEFINER, revoke temporary schema CREATE, and grant the executor function EXECUTE only | implemented_on_active_pr | | No direct DML bypass from runtime capability | ADR 0015 | `tests/test_employment_separation_capability_postgres.sh` requires the executor to have no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE rights on governed People/audit/outbox relations while still crossing the function boundary | awaiting_foundation_registration | | Capability-test failure isolation | #314 | the unrelated probe role uses collision-resistant per-execution identity; failure cleanup is best-effort without masking the causal error, while nominal success requires strict verified role cleanup | implemented_on_active_pr | | Real concurrent-first serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on an advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | | Canonical CI ownership | #311 | both focused PostgreSQL contracts must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | +| Buyer HTTP/OpenAPI journey | #314 | authenticated request parsing, explicit separation confirmation/evidence body, response/error schema, and p95/E2E evidence must consume the application boundary without exposing owner-role or direct-DML capabilities | not_started | | Rehire | #302 | existing Person + new Employment must cite authoritative prior separation and fresh rehire authority | not_started | ## Current acceptance boundary -`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015 and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is now separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. +`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015, the application/persistence ports, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. + +`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision and issues no direct People or audit/outbox DML. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. The existing Foundation workflow remains owned by its canonical Foundation stack. The new PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. @@ -32,6 +37,8 @@ The existing Foundation workflow remains owned by its canonical Foundation stack Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL contracts rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. -The People runtime owner must then bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. +The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. + +The next buyer-visible gap is the HTTP/OpenAPI journey. It must preserve the application command's exact Employment/version/date/reason/evidence/confirmation/idempotency contract, sanitize backend errors, and expose replay/recorded-time semantics without leaking privileged database details. It must be added on the canonical People route owner rather than by a parallel service. Once those owner boundaries are reconciled and exact-head hosted evidence is green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From d5cc990ec98c17fe186aeba8e04c7992e9421828 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:02:13 +0900 Subject: [PATCH 164/269] test(people): require governed separation HTTP boundary --- .../tests/test_employment_separation_http.py | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 services/people-api/tests/test_employment_separation_http.py diff --git a/services/people-api/tests/test_employment_separation_http.py b/services/people-api/tests/test_employment_separation_http.py new file mode 100644 index 000000000..55e8da32d --- /dev/null +++ b/services/people-api/tests/test_employment_separation_http.py @@ -0,0 +1,265 @@ +"""Executable HTTP contracts for governed Employment separation.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import json +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy +from orgmetra_people_api import AuthenticatedPrincipal, AuthenticationFailed +from orgmetra_people_api.separation import ( + EmploymentSeparationCommand, + EmploymentSeparationIntegrityError, + EmploymentSeparationResult, +) +from orgmetra_people_api.separation_http import EmploymentSeparationAsgiApp + +TENANT = UUID("0198a412-9000-7000-8000-000000000001") +PERSON = UUID("0198a412-9000-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-9000-7000-8000-000000000030") +EXPECTED_VERSION = UUID("0198a412-9000-7000-8000-000000000031") +TERMINAL_VERSION = UUID("0198a412-9000-7000-8000-000000000032") +AUDIT_EVENT = UUID("0198a412-9000-7000-8000-000000000080") +OUTBOX = UUID("0198a412-9000-7000-8000-000000000081") +RECORDED_AT = datetime(2026, 9, 12, 14, 45, tzinfo=timezone.utc) +ROUTE = "/v1/employment-separations" +IDEMPOTENCY_KEY = b"employment-separation-http-17" + + +def valid_headers(*, purpose: bytes = b"workforce_admin") -> list[tuple[bytes, bytes]]: + """Return the authenticated command headers for one separation request.""" + return [ + (b"authorization", b"Bearer opaque-token"), + (b"content-type", b"application/json"), + (b"idempotency-key", IDEMPOTENCY_KEY), + (b"x-tenant-reference", str(TENANT).encode("ascii")), + (b"x-actor-reference", b"keyverse_subject:people-operator-17"), + (b"x-purpose-code", purpose), + ] + + +def request_body(**overrides: object) -> bytes: + """Return one PII-minimized separation command body.""" + payload: dict[str, object] = { + "person_record_id": str(PERSON), + "employment_record_id": str(EMPLOYMENT), + "expected_employment_record_version_id": str(EXPECTED_VERSION), + "separation_effective_on": "2026-10-01", + "separation_reason_code": "voluntary_resignation", + "evidence_reference": "separation_packet:case-17", + "evidence_version_code": "v1", + "confirmation_reference": "human_confirmation:case-17", + } + payload.update(overrides) + return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + + +class FakeAuthenticator: + """Return one principal while retaining no bearer-token material in results.""" + + 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: + self.tokens.append(bearer_token) + if self.error is not None: + raise self.error + return self.principal + + +class RecordingSeparationPort: + """Capture authorized separation calls or raise a configured persistence error.""" + + def __init__(self, *, error: Exception | None = None, replayed: bool = False) -> None: + self.error = error + self.replayed = replayed + self.calls: list[tuple[EmploymentSeparationCommand, object]] = [] + + def separate_employment(self, *, command: EmploymentSeparationCommand, authorization: object) -> EmploymentSeparationResult: + self.calls.append((command, authorization)) + if self.error is not None: + raise self.error + return EmploymentSeparationResult( + employment_record_id=command.employment_record_id, + separated_employment_record_version_id=TERMINAL_VERSION, + recorded_at=RECORDED_AT, + replayed=self.replayed, + ) + + +class EmploymentSeparationHttpTests(unittest.IsolatedAsyncioTestCase): + """Prove the buyer route preserves authorization, evidence, replay, and error boundaries.""" + + def setUp(self) -> None: + self.principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:people-operator-17", + granted_scope_codes=frozenset({"orgmetra.people.write"}), + ) + self.policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="employment-separation-v1", + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="separate_record", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({"employment_record"}), + ) + + def _app( + self, + *, + authenticator: object | None = None, + policy: object | None = None, + separation_port: object | None = None, + ) -> EmploymentSeparationAsgiApp: + generated = iter((AUDIT_EVENT, OUTBOX)) + return EmploymentSeparationAsgiApp( + authenticator=authenticator if authenticator is not None else FakeAuthenticator(self.principal), + policy=policy if policy is not None else self.policy, + separation_port=separation_port if separation_port is not None else RecordingSeparationPort(), + id_factory=generated.__next__, + ) + + async def _request( + self, + app: EmploymentSeparationAsgiApp, + *, + method: str = "POST", + path: object = ROUTE, + headers: object | None = None, + body: object | None = None, + ) -> tuple[int, dict[bytes, bytes], dict[str, object]]: + scope = { + "type": "http", + "method": method, + "path": path, + "query_string": b"", + "headers": headers if headers is not None else valid_headers(), + } + messages: list[dict[str, object]] = [] + received = False + + async def receive() -> dict[str, object]: + nonlocal received + if received: + return {"type": "http.disconnect"} + received = True + return { + "type": "http.request", + "body": body if body is not None else request_body(), + "more_body": False, + } + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + await app(scope, receive, send) + start, response_body = messages + return int(start["status"]), dict(start["headers"]), json.loads(bytes(response_body["body"])) + + def test_constructor_requires_governed_dependencies(self) -> None: + with self.assertRaisesRegex(TypeError, "authenticator"): + self._app(authenticator=object()) + with self.assertRaisesRegex(TypeError, "policy"): + self._app(policy=object()) + with self.assertRaisesRegex(TypeError, "separation_port"): + self._app(separation_port=object()) + + async def test_success_returns_database_owned_terminal_version_and_replay_evidence(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = RecordingSeparationPort(replayed=True) + status, headers, payload = await self._request( + self._app(authenticator=authenticator, separation_port=port) + ) + + 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, + { + "employment_record_id": str(EMPLOYMENT), + "separated_employment_record_version_id": str(TERMINAL_VERSION), + "recorded_at": "2026-09-12T14:45:00Z", + "replayed": True, + }, + ) + self.assertEqual(authenticator.tokens, ["opaque-token"]) + command, authorization = port.calls[0] + self.assertEqual(command.person_record_id, PERSON) + self.assertEqual(command.employment_record_id, EMPLOYMENT) + self.assertEqual(command.expected_employment_record_version_id, EXPECTED_VERSION) + self.assertEqual(command.separation_effective_on, date(2026, 10, 1)) + self.assertEqual(command.audit_event_record_id, AUDIT_EVENT) + self.assertEqual(command.outbox_delivery_record_id, OUTBOX) + self.assertEqual(command.idempotency_key, IDEMPOTENCY_KEY.decode("ascii")) + self.assertEqual(authorization.resource_reference, f"employment_record:{EMPLOYMENT.hex}") + + async def test_route_method_and_purpose_fail_without_persistence(self) -> None: + port = RecordingSeparationPort() + app = self._app(separation_port=port) + cases = ( + {"method": "GET", "expected": 405}, + {"path": "/v1/unknown", "expected": 404}, + {"headers": valid_headers(purpose=b"benefits_admin"), "expected": 403}, + ) + for case in cases: + with self.subTest(case=case): + expected = int(case.pop("expected")) + status, _, payload = await self._request(app, **case) + self.assertEqual(status, expected) + self.assertIn("error_code", payload) + self.assertEqual(port.calls, []) + + async def test_authentication_and_tenant_actor_binding_fail_closed(self) -> None: + denied = self._app( + authenticator=FakeAuthenticator(self.principal, error=AuthenticationFailed("bad token")) + ) + status, headers, _ = await self._request(denied) + self.assertEqual(status, 401) + self.assertEqual(headers[b"www-authenticate"], b"Bearer") + + other_principal = AuthenticatedPrincipal( + tenant_record_id=UUID("0198a412-9000-7000-8000-000000000099"), + actor_reference=self.principal.actor_reference, + granted_scope_codes=self.principal.granted_scope_codes, + ) + status, _, payload = await self._request( + self._app(authenticator=FakeAuthenticator(other_principal)) + ) + self.assertEqual(status, 403) + self.assertEqual(payload["error_code"], "access_denied") + + async def test_exact_body_and_domain_failures_are_client_safe(self) -> None: + malformed_cases = ( + request_body(extra="forbidden"), + request_body(employment_record_id="not-a-uuid"), + request_body(separation_effective_on="2026-10-01T00:00:00Z"), + request_body(separation_reason_code="Voluntary resignation"), + ) + for body in malformed_cases: + with self.subTest(body=body): + status, _, payload = await self._request(self._app(), body=body) + self.assertEqual(status, 400) + self.assertEqual(payload["error_code"], "invalid_request") + + denied_port = RecordingSeparationPort(error=AuthorizationDeniedError("denied")) + status, _, payload = await self._request(self._app(separation_port=denied_port)) + self.assertEqual(status, 403) + self.assertEqual(payload["error_code"], "access_denied") + + conflict_port = RecordingSeparationPort(error=EmploymentSeparationIntegrityError("sensitive backend detail")) + status, _, payload = await self._request(self._app(separation_port=conflict_port)) + self.assertEqual(status, 409) + self.assertEqual(payload["error_code"], "separation_conflict") + self.assertNotIn("sensitive backend detail", json.dumps(payload)) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 54761aacdcb9976fd4f4d802f40d7d79e40ef4ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:03:02 +0900 Subject: [PATCH 165/269] feat(people): expose governed separation HTTP boundary --- .../orgmetra_people_api/separation_http.py | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 services/people-api/src/orgmetra_people_api/separation_http.py diff --git a/services/people-api/src/orgmetra_people_api/separation_http.py b/services/people-api/src/orgmetra_people_api/separation_http.py new file mode 100644 index 000000000..2a0323433 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/separation_http.py @@ -0,0 +1,312 @@ +"""Dependency-light ASGI boundary for governed Employment separation.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import date, timezone +import logging +import re +from secrets import token_urlsafe +from typing import Callable, Mapping +from uuid import UUID, uuid4 + +from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy + +from orgmetra_people_api.auth import ( + AuthenticatedPrincipal, + AuthenticationFailed, + TokenAuthenticator, + extract_bearer_token, +) +from orgmetra_people_api.hire_http import ( + _InvalidHttpRequest, + _PayloadTooLarge, + _UnsupportedMediaType, + _read_json_object, + _require_json_content_type, +) +from orgmetra_people_api.http import AsgiReceive, AsgiSend, _authorization_header, _send_json +from orgmetra_people_api.mutation_http import _parse_command_headers, _send_error +from orgmetra_people_api.mutations import PeopleMutationNotFound +from orgmetra_people_api.separation import ( + EmploymentSeparationCommand, + EmploymentSeparationIntegrityError, + EmploymentSeparationPort, + separate_employment_record, +) + +_LOGGER = logging.getLogger(__name__) +_ROUTE = "/v1/employment-separations" +_BODY_KEYS = frozenset( + { + "person_record_id", + "employment_record_id", + "expected_employment_record_version_id", + "separation_effective_on", + "separation_reason_code", + "evidence_reference", + "evidence_version_code", + "confirmation_reference", + } +) +_RFC3339_FULL_DATE = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z", flags=re.ASCII) +_ACTOR_REFERENCE_PATTERN = re.compile(r"\A[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*\Z", flags=re.ASCII) +_MAX_UUID_INT = (1 << 128) - 1 +_SUPPORT_REFERENCE_RANDOM_BYTES = 24 + + +def _operational_uuid(field_name: str, value: object) -> UUID: + """Parse one HTTP UUID while rejecting reserved sentinel identities.""" + if type(value) is not str: + raise _InvalidHttpRequest(f"{field_name} must be a UUID string") + try: + parsed = UUID(value) + except ValueError as error: + raise _InvalidHttpRequest(f"{field_name} must be a UUID string") from error + if parsed.int in (0, _MAX_UUID_INT): + raise _InvalidHttpRequest(f"{field_name} must be an operational UUID") + return parsed + + +def _business_date(value: object) -> date: + """Parse the published full-date representation without datetime coercion.""" + if type(value) is not str or _RFC3339_FULL_DATE.fullmatch(value) is None: + raise _InvalidHttpRequest("separation_effective_on must be an RFC 3339 full-date") + try: + return date.fromisoformat(value) + except ValueError as error: + raise _InvalidHttpRequest("separation_effective_on must be an RFC 3339 full-date") from error + + +def _command_from_payload( + *, + tenant_record_id: UUID, + idempotency_key: str, + payload: Mapping[str, object], + id_factory: Callable[[], UUID], +) -> EmploymentSeparationCommand: + """Build one exact separation command without admitting extra body fields.""" + if frozenset(payload) != _BODY_KEYS: + raise _InvalidHttpRequest("separation body must contain exactly the published fields") + return EmploymentSeparationCommand( + tenant_record_id=tenant_record_id, + person_record_id=_operational_uuid("person_record_id", payload["person_record_id"]), + employment_record_id=_operational_uuid("employment_record_id", payload["employment_record_id"]), + expected_employment_record_version_id=_operational_uuid( + "expected_employment_record_version_id", + payload["expected_employment_record_version_id"], + ), + separation_effective_on=_business_date(payload["separation_effective_on"]), + separation_reason_code=payload["separation_reason_code"], # type: ignore[arg-type] + evidence_reference=payload["evidence_reference"], # type: ignore[arg-type] + evidence_version_code=payload["evidence_version_code"], # type: ignore[arg-type] + confirmation_reference=payload["confirmation_reference"], # type: ignore[arg-type] + idempotency_key=idempotency_key, + audit_event_record_id=id_factory(), + outbox_delivery_record_id=id_factory(), + ) + + +@dataclass(frozen=True, slots=True) +class EmploymentSeparationAsgiApp: + """Expose one purpose-bound buyer route for authoritative Employment separation.""" + + authenticator: TokenAuthenticator + policy: PurposeBoundAccessPolicy + separation_port: EmploymentSeparationPort + id_factory: Callable[[], UUID] = uuid4 + + def __post_init__(self) -> None: + """Reject incomplete dependency injection before serving high-impact writes.""" + 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.separation_port, EmploymentSeparationPort): + raise TypeError("separation_port must implement EmploymentSeparationPort") + if not callable(self.id_factory): + raise TypeError("id_factory must be callable") + + async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send: AsgiSend) -> None: + """Serve one separation without leaking bearer tokens or database capabilities.""" + if scope.get("type") != "http": + raise ValueError("EmploymentSeparationAsgiApp accepts only HTTP ASGI scopes") + if scope.get("method") != "POST": + await _send_error( + send, + status=405, + payload={"error": "method_not_allowed", "message": "Use POST for Employment separation."}, + extra_headers=((b"allow", b"POST"),), + ) + return + if scope.get("path") != _ROUTE: + await _send_error( + send, + status=404, + payload={"error": "route_not_found", "message": f"Use {_ROUTE}."}, + ) + return + + try: + headers = _parse_command_headers(scope) + _require_json_content_type(scope) + if _ACTOR_REFERENCE_PATTERN.fullmatch(headers.actor_reference) is None: + raise _InvalidHttpRequest("X-Actor-Reference must be a namespaced opaque reference") + except _UnsupportedMediaType: + await _send_error( + send, + status=415, + payload={"error": "unsupported_media_type", "message": "Send application/json and retry."}, + ) + return + except (_InvalidHttpRequest, ValueError, TypeError): + await _send_error( + send, + status=400, + payload={"error": "invalid_request", "message": "Correct the command headers and retry."}, + ) + return + + try: + bearer_token = extract_bearer_token(_authorization_header(scope)) + principal = await self.authenticator.authenticate(bearer_token) + if not isinstance(principal, AuthenticatedPrincipal): + raise TypeError("authenticator returned an invalid principal") + except AuthenticationFailed: + await _send_error( + send, + status=401, + payload={"error": "authentication_required", "message": "Provide one valid Bearer credential and retry."}, + extra_headers=((b"www-authenticate", b"Bearer"),), + ) + return + except Exception as error: # noqa: BLE001 - identity backend failures must remain client-safe. + support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + _LOGGER.error( + "Employment separation authentication failed", + extra={ + "tenant_record_id": str(headers.tenant_record_id), + "exception_type": type(error).__name__, + "support_reference": support_reference, + }, + ) + await _send_error( + send, + status=500, + payload={ + "error": "internal_error", + "message": "Retry later or contact an Orgmetra operator with the support reference.", + }, + support_reference=support_reference, + ) + return + + if ( + principal.tenant_record_id != headers.tenant_record_id + or principal.actor_reference != headers.actor_reference + ): + await _send_error( + send, + status=403, + payload={"error": "access_denied", "message": "Use the tenant and actor bound to the authenticated credential."}, + ) + return + if headers.purpose_code != "workforce_admin": + await _send_error( + send, + status=403, + payload={"error": "access_denied", "message": "Employment separation requires workforce_admin purpose."}, + ) + return + + try: + payload = await _read_json_object(receive) + command = _command_from_payload( + tenant_record_id=headers.tenant_record_id, + idempotency_key=headers.idempotency_key, + payload=payload, + id_factory=self.id_factory, + ) + except _PayloadTooLarge: + await _send_error( + send, + status=413, + payload={"error": "payload_too_large", "message": "Send one bounded JSON separation command and retry."}, + ) + return + except (_InvalidHttpRequest, ValueError, TypeError, StopIteration): + await _send_error( + send, + status=400, + payload={"error": "invalid_request", "message": "Correct the separation command and retry."}, + ) + return + + try: + result = await asyncio.to_thread( + separate_employment_record, + principal=principal, + command=command, + purpose_code=headers.purpose_code, + policy=self.policy, + separation_port=self.separation_port, + ) + except AuthorizationDeniedError: + await _send_error( + send, + status=403, + payload={"error": "access_denied", "message": "Request the scope authorized for this exact Employment separation."}, + ) + return + except PeopleMutationNotFound: + await _send_error( + send, + status=404, + payload={"error": "record_not_found", "message": "Verify the Person, Employment, and expected version, then retry."}, + ) + return + except EmploymentSeparationIntegrityError: + await _send_error( + send, + status=409, + payload={ + "error": "separation_conflict", + "message": "Refresh Employment and Assignment state, confirm the evidence, and retry with a new key if semantics changed.", + }, + ) + return + except Exception as error: # noqa: BLE001 - persistence details must never escape the HTTP boundary. + support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + _LOGGER.error( + "Employment separation persistence failed", + extra={ + "tenant_record_id": str(headers.tenant_record_id), + "employment_record_id": str(command.employment_record_id), + "correlation_reference": f"audit_event_record:{command.audit_event_record_id.hex}", + "exception_type": type(error).__name__, + "support_reference": support_reference, + }, + ) + await _send_error( + send, + status=500, + payload={ + "error": "internal_error", + "message": "Retry later or contact an Orgmetra operator with the support reference.", + }, + support_reference=support_reference, + ) + return + + recorded_at = result.recorded_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + await _send_json( + send, + status=200, + payload={ + "employment_record_id": str(result.employment_record_id), + "separated_employment_record_version_id": str(result.separated_employment_record_version_id), + "recorded_at": recorded_at, + "replayed": result.replayed, + }, + ) From 40aa7dd3fcf35db71e3b43e70600acef6aaaf022 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:03:32 +0900 Subject: [PATCH 166/269] feat(people): export separation HTTP boundary --- services/people-api/src/orgmetra_people_api/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index 0a83decf7..11c2266be 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -51,6 +51,7 @@ EmploymentSeparationResult, separate_employment_record, ) +from orgmetra_people_api.separation_http import EmploymentSeparationAsgiApp __all__ = [ "AuthenticatedPrincipal", @@ -80,6 +81,7 @@ "AssignmentMutationResult", "EmploymentMutationCommand", "EmploymentMutationResult", + "EmploymentSeparationAsgiApp", "EmploymentSeparationCommand", "EmploymentSeparationIntegrityError", "EmploymentSeparationPort", From cb144942b7d942038e54a30e44989cf9ce56cf3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:05:08 +0900 Subject: [PATCH 167/269] test(people): fail server-generated separation ids as operational errors --- .../tests/test_employment_separation_http.py | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/services/people-api/tests/test_employment_separation_http.py b/services/people-api/tests/test_employment_separation_http.py index 55e8da32d..e4b770c0c 100644 --- a/services/people-api/tests/test_employment_separation_http.py +++ b/services/people-api/tests/test_employment_separation_http.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from datetime import date, datetime, timezone import json import unittest @@ -116,13 +117,14 @@ def _app( authenticator: object | None = None, policy: object | None = None, separation_port: object | None = None, + id_factory: Callable[[], UUID] | None = None, ) -> EmploymentSeparationAsgiApp: generated = iter((AUDIT_EVENT, OUTBOX)) return EmploymentSeparationAsgiApp( authenticator=authenticator if authenticator is not None else FakeAuthenticator(self.principal), policy=policy if policy is not None else self.policy, separation_port=separation_port if separation_port is not None else RecordingSeparationPort(), - id_factory=generated.__next__, + id_factory=id_factory if id_factory is not None else generated.__next__, ) async def _request( @@ -169,6 +171,13 @@ def test_constructor_requires_governed_dependencies(self) -> None: self._app(policy=object()) with self.assertRaisesRegex(TypeError, "separation_port"): self._app(separation_port=object()) + with self.assertRaisesRegex(TypeError, "id_factory"): + EmploymentSeparationAsgiApp( + authenticator=FakeAuthenticator(self.principal), + policy=self.policy, + separation_port=RecordingSeparationPort(), + id_factory=17, # type: ignore[arg-type] + ) async def test_success_returns_database_owned_terminal_version_and_replay_evidence(self) -> None: authenticator = FakeAuthenticator(self.principal) @@ -205,14 +214,13 @@ async def test_route_method_and_purpose_fail_without_persistence(self) -> None: port = RecordingSeparationPort() app = self._app(separation_port=port) cases = ( - {"method": "GET", "expected": 405}, - {"path": "/v1/unknown", "expected": 404}, - {"headers": valid_headers(purpose=b"benefits_admin"), "expected": 403}, + ({"method": "GET"}, 405), + ({"path": "/v1/unknown"}, 404), + ({"headers": valid_headers(purpose=b"benefits_admin")}, 403), ) - for case in cases: - with self.subTest(case=case): - expected = int(case.pop("expected")) - status, _, payload = await self._request(app, **case) + for request, expected in cases: + with self.subTest(request=request): + status, _, payload = await self._request(app, **request) self.assertEqual(status, expected) self.assertIn("error_code", payload) self.assertEqual(port.calls, []) @@ -260,6 +268,21 @@ async def test_exact_body_and_domain_failures_are_client_safe(self) -> None: self.assertEqual(payload["error_code"], "separation_conflict") self.assertNotIn("sensitive backend detail", json.dumps(payload)) + async def test_server_generated_identity_failure_is_internal_not_client_error(self) -> None: + port = RecordingSeparationPort() + + def unavailable_id_factory() -> UUID: + raise RuntimeError("entropy source unavailable") + + status, _, payload = await self._request( + self._app(separation_port=port, id_factory=unavailable_id_factory) + ) + + self.assertEqual(status, 500) + self.assertEqual(payload["error_code"], "internal_error") + self.assertNotIn("entropy source unavailable", json.dumps(payload)) + self.assertEqual(port.calls, []) + if __name__ == "__main__": # pragma: no cover unittest.main() From 4a142ef0579e63de95bec7e4e4916701cebc7a20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:06:00 +0900 Subject: [PATCH 168/269] fix(people): classify separation server identity failures correctly --- .../orgmetra_people_api/separation_http.py | 63 ++++++++++++++++--- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/separation_http.py b/services/people-api/src/orgmetra_people_api/separation_http.py index 2a0323433..dccf975e2 100644 --- a/services/people-api/src/orgmetra_people_api/separation_http.py +++ b/services/people-api/src/orgmetra_people_api/separation_http.py @@ -69,6 +69,14 @@ def _operational_uuid(field_name: str, value: object) -> UUID: return parsed +def _generated_operational_uuid(field_name: str, id_factory: Callable[[], UUID]) -> UUID: + """Treat malformed server-generated identities as operational failure, never caller error.""" + value = id_factory() + if type(value) is not UUID or value.int in (0, _MAX_UUID_INT): + raise RuntimeError(f"{field_name} factory did not return an operational UUID") + return UUID(int=value.int) + + def _business_date(value: object) -> date: """Parse the published full-date representation without datetime coercion.""" if type(value) is not str or _RFC3339_FULL_DATE.fullmatch(value) is None: @@ -84,7 +92,8 @@ def _command_from_payload( tenant_record_id: UUID, idempotency_key: str, payload: Mapping[str, object], - id_factory: Callable[[], UUID], + audit_event_record_id: UUID, + outbox_delivery_record_id: UUID, ) -> EmploymentSeparationCommand: """Build one exact separation command without admitting extra body fields.""" if frozenset(payload) != _BODY_KEYS: @@ -103,8 +112,8 @@ def _command_from_payload( evidence_version_code=payload["evidence_version_code"], # type: ignore[arg-type] confirmation_reference=payload["confirmation_reference"], # type: ignore[arg-type] idempotency_key=idempotency_key, - audit_event_record_id=id_factory(), - outbox_delivery_record_id=id_factory(), + audit_event_record_id=audit_event_record_id, + outbox_delivery_record_id=outbox_delivery_record_id, ) @@ -222,12 +231,6 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send try: payload = await _read_json_object(receive) - command = _command_from_payload( - tenant_record_id=headers.tenant_record_id, - idempotency_key=headers.idempotency_key, - payload=payload, - id_factory=self.id_factory, - ) except _PayloadTooLarge: await _send_error( send, @@ -235,7 +238,47 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send payload={"error": "payload_too_large", "message": "Send one bounded JSON separation command and retry."}, ) return - except (_InvalidHttpRequest, ValueError, TypeError, StopIteration): + except (_InvalidHttpRequest, ValueError, TypeError): + await _send_error( + send, + status=400, + payload={"error": "invalid_request", "message": "Correct the separation command and retry."}, + ) + return + + try: + audit_event_record_id = _generated_operational_uuid("audit_event_record_id", self.id_factory) + outbox_delivery_record_id = _generated_operational_uuid("outbox_delivery_record_id", self.id_factory) + except Exception as error: # noqa: BLE001 - server identity generation is an operational dependency. + support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + _LOGGER.error( + "Employment separation server identity generation failed", + extra={ + "tenant_record_id": str(headers.tenant_record_id), + "exception_type": type(error).__name__, + "support_reference": support_reference, + }, + ) + await _send_error( + send, + status=500, + payload={ + "error": "internal_error", + "message": "Retry later or contact an Orgmetra operator with the support reference.", + }, + support_reference=support_reference, + ) + return + + try: + command = _command_from_payload( + tenant_record_id=headers.tenant_record_id, + idempotency_key=headers.idempotency_key, + payload=payload, + audit_event_record_id=audit_event_record_id, + outbox_delivery_record_id=outbox_delivery_record_id, + ) + except (_InvalidHttpRequest, ValueError, TypeError): await _send_error( send, status=400, From b11440d31fd17d0b4e9c7c7a968e7c8d76c57eea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:10:57 +0900 Subject: [PATCH 169/269] fix(people): repair separation adapter authorization fixtures --- .../people-api/tests/test_postgres_employment_separation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_postgres_employment_separation.py b/services/people-api/tests/test_postgres_employment_separation.py index f65e89efb..461728e0b 100644 --- a/services/people-api/tests/test_postgres_employment_separation.py +++ b/services/people-api/tests/test_postgres_employment_separation.py @@ -181,8 +181,8 @@ def test_rejects_authorization_that_does_not_match_exact_operation(self) -> None authorization(purpose_code="benefits_admin"), authorization(operation_code="create_record"), authorization(resource_kind="assignment_record"), - authorization(requested_fields=frozenset({"assignment_record")), - authorization(authorized_fields=frozenset({"assignment_record")), + authorization(requested_fields=frozenset({"assignment_record"})), + authorization(authorized_fields=frozenset({"assignment_record"})), ) for decision in cases: with self.subTest(decision=decision), self.assertRaises(EmploymentSeparationIntegrityError): From 8172c641d0c06c8cecb4c635c25f360f44cb17cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:11:36 +0900 Subject: [PATCH 170/269] docs(people): document governed separation buyer route --- services/people-api/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/people-api/README.md b/services/people-api/README.md index 548a83446..cb9209ad2 100644 --- a/services/people-api/README.md +++ b/services/people-api/README.md @@ -16,4 +16,6 @@ The People API quality workflow is part of this contract and must run for pull r `PeopleMutationAsgiApp` exposes the governed People mutation API as `POST /v1/employment-records`, `POST /v1/position-records`, and `POST /v1/assignment-records`. Each command requires an idempotency key, tenant/actor/purpose headers, a non-blank accountable decision reason, human confirmation, and versioned evidence. The HTTP boundary enforces the exact OpenAPI evidence-object shape and cardinality, rejects additional fields and duplicate evidence items, and canonicalizes the complete reference/version set independent of array order. It first derives a PII-minimized `evidence_set_v1:` identity and then binds that identity together with the exact validated decision reason into `governance_evidence_v1:`. The free-text reason and raw evidence references are not copied into the portable audit envelope, but any reason/reference/version drift changes the governance binding, the immutable audit correlation evidence, and the durable idempotency command digest. A caller therefore cannot reuse the same key after silently changing the high-impact rationale and receive an incorrect replay. The validated `Idempotency-Key` is copied onto the application command and into `PostgresPeopleMutationPort`. Employment and assignment writes require a current `candidate_worker_conversion_record` (`recorded_to IS NULL`) and reuse `orgmetra_hris_kernel` exclusivity and assignment-coverage checks before the port inserts the authoritative fact, calls `record_audit_outbox_event`, and stores `people_mutation_idempotency_record` in the same transaction. A matching retry returns the first committed identity without a second HRIS, audit, or outbox fact. Successful responses contain only opaque record identifiers. +`EmploymentSeparationAsgiApp` exposes authoritative Employment separation as `POST /v1/employment-separations`. The request is authenticated and bound to the exact tenant and actor before the command body is accepted, requires `workforce_admin` purpose and the existing Keyverse `separate_record` decision for the exact Employment, and carries the expected current Employment version, business-effective separation date, controlled reason, versioned evidence, human confirmation, and idempotency key. The ASGI boundary generates only server-owned audit/outbox identities; failure of that identity source is an operational `500`, never misreported as caller validation. Persistence remains behind `PostgresEmploymentSeparationPort` and the database's deny-default separation capability. A successful response exposes only the Employment identity, terminal version identity, database-owned recorded timestamp, and whether the result was an exact replay. Backend diagnostics, owner-role details, bearer tokens, and direct-DML capabilities remain non-client-visible. + The superseded persistence model must not be restored, and the service must not use direct cross-service application-table SQL. From 1d7b157300e8b9fe9ab23afd0a6de44983e7d8d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:13:14 +0900 Subject: [PATCH 171/269] docs(people): trace separation HTTP boundary --- docs/traceability/employment-separation.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index fc9b201dd..1d74a0403 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -22,14 +22,15 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Real concurrent-first serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on an advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | | Canonical CI ownership | #311 | both focused PostgreSQL contracts must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | -| Buyer HTTP/OpenAPI journey | #314 | authenticated request parsing, explicit separation confirmation/evidence body, response/error schema, and p95/E2E evidence must consume the application boundary without exposing owner-role or direct-DML capabilities | not_started | -| Rehire | #302 | existing Person + new Employment must cite authoritative prior separation and fresh rehire authority | not_started | +| Buyer HTTP boundary | #314 | `EmploymentSeparationAsgiApp` authenticates and binds tenant/actor before the body is accepted, requires `workforce_admin`, preserves expected-version/date/reason/evidence/confirmation/idempotency semantics, generates server-owned audit/outbox identities, returns replay plus DB-owned recorded time, and sanitizes backend failures | implemented_on_active_pr | +| Canonical OpenAPI and buyer performance journey | #314 | `schemas/openapi.yaml` must publish the separation request/result/error contract and the exact route must obtain buyer-path p95/E2E evidence without exposing owner-role or direct-DML capabilities | planned | +| Rehire | #302 | existing Person + new Employment must cite authoritative prior separation and fresh rehire authority | planned | ## Current acceptance boundary -`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015, the application/persistence ports, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. +`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015, the application/persistence ports, the buyer-facing ASGI route, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. -`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision and issues no direct People or audit/outbox DML. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. +`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision and issues no direct People or audit/outbox DML. `separation_http.py` is the buyer request edge: it binds authentication, tenant, actor, purpose, exact command fields and client-safe error semantics before delegating the synchronous persistence operation off the ASGI event loop. Server-generated audit/outbox identity failure is an operational failure and is not misclassified as invalid buyer input. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. The existing Foundation workflow remains owned by its canonical Foundation stack. The new PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. @@ -39,6 +40,6 @@ Foundation reconciliation should discover `tests/test_employment_separation_post The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. -The next buyer-visible gap is the HTTP/OpenAPI journey. It must preserve the application command's exact Employment/version/date/reason/evidence/confirmation/idempotency contract, sanitize backend errors, and expose replay/recorded-time semantics without leaking privileged database details. It must be added on the canonical People route owner rather than by a parallel service. +The remaining buyer-visible gap is to publish the already-implemented HTTP command in the canonical OpenAPI contract, add exact schema/route regression coverage, and then measure the route under the buyer-path performance/E2E lane. No performance claim is made from unit tests or queued CI alone. Once those owner boundaries are reconciled and exact-head hosted evidence is green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From af5d6d630bc1275badc7cc1ec197396548d78507 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:14:59 +0900 Subject: [PATCH 172/269] feat(people): publish Employment separation OpenAPI contract --- schemas/openapi.yaml | 120 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index c03ffab0b..b5d9ba070 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -179,6 +179,49 @@ paths: $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/IdempotencyConflict' + /employment-separations: + post: + operationId: separateEmploymentRecord + summary: Record an authoritative bitemporal Employment separation + tags: + - people-core + security: + - keyverse_oidc: + - orgmetra.people.write + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/TenantReference' + - $ref: '#/components/parameters/ActorReference' + - $ref: '#/components/parameters/PurposeCode' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SeparateEmploymentRecordCommand' + responses: + '200': + description: Separated or exact replay of the first committed separation. + content: + application/json: + schema: + $ref: '#/components/schemas/EmploymentSeparationResult' + '400': + $ref: '#/components/responses/InvalidCommand' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/RecordNotFound' + '409': + $ref: '#/components/responses/IdempotencyConflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '500': + $ref: '#/components/responses/InternalError' /position-records: post: operationId: createPositionRecord @@ -569,6 +612,63 @@ components: employment_record_id: type: string format: uuid + SeparateEmploymentRecordCommand: + type: object + additionalProperties: false + required: + - person_record_id + - employment_record_id + - expected_employment_record_version_id + - separation_effective_on + - separation_reason_code + - evidence_reference + - evidence_version_code + - confirmation_reference + properties: + person_record_id: + type: string + format: uuid + employment_record_id: + type: string + format: uuid + expected_employment_record_version_id: + type: string + format: uuid + separation_effective_on: + type: string + format: date + separation_reason_code: + type: string + pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + evidence_reference: + type: string + pattern: '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' + evidence_version_code: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$' + confirmation_reference: + type: string + pattern: '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' + EmploymentSeparationResult: + type: object + additionalProperties: false + required: + - employment_record_id + - separated_employment_record_version_id + - recorded_at + - replayed + properties: + employment_record_id: + type: string + format: uuid + separated_employment_record_version_id: + type: string + format: uuid + recorded_at: + type: string + format: date-time + replayed: + type: boolean CreatePositionRecordCommand: type: object additionalProperties: false @@ -1012,8 +1112,26 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + RecordNotFound: + description: The authorized Person, Employment, or expected current version does not exist. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + PayloadTooLarge: + description: The request body exceeds the bounded command size. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' UnsupportedMediaType: - description: The job-analysis POST body is missing application/json media type. + description: The POST body is missing application/json media type. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + InternalError: + description: An operational dependency failed without exposing internal diagnostics. content: application/json: schema: From faaf190b3c5f82f6266bd700a4e285f86ee9b719 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:16:35 +0900 Subject: [PATCH 173/269] test(people): require exact separation OpenAPI contract --- ..._employment_separation_openapi_contract.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 services/people-api/tests/test_employment_separation_openapi_contract.py diff --git a/services/people-api/tests/test_employment_separation_openapi_contract.py b/services/people-api/tests/test_employment_separation_openapi_contract.py new file mode 100644 index 000000000..095081ea4 --- /dev/null +++ b/services/people-api/tests/test_employment_separation_openapi_contract.py @@ -0,0 +1,99 @@ +"""Executable publication contract for the Employment separation buyer route.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_OPENAPI_PATH = _REPOSITORY_ROOT / "schemas" / "openapi.yaml" + + +def _yaml_block(document: str, marker: str) -> str: + """Return one indentation-bounded YAML block from the canonical contract.""" + lines = document.splitlines() + try: + start = lines.index(marker) + except ValueError as error: + raise AssertionError(f"missing OpenAPI marker: {marker}") from error + marker_indent = len(marker) - len(marker.lstrip()) + block: list[str] = [] + for line in lines[start + 1 :]: + if line.strip() and len(line) - len(line.lstrip()) <= marker_indent: + break + block.append(line) + return "\n".join(block) + + +class EmploymentSeparationOpenApiTests(unittest.TestCase): + """Keep the published buyer contract aligned with the implemented ASGI boundary.""" + + @classmethod + def setUpClass(cls) -> None: + cls.document = _OPENAPI_PATH.read_text(encoding="utf-8") + + def test_route_publishes_exact_governed_operation(self) -> None: + block = _yaml_block(self.document, " /employment-separations:") + for fragment in ( + "operationId: separateEmploymentRecord", + " - orgmetra.people.write", + "$ref: '#/components/parameters/IdempotencyKey'", + "$ref: '#/components/parameters/TenantReference'", + "$ref: '#/components/parameters/ActorReference'", + "$ref: '#/components/parameters/PurposeCode'", + "$ref: '#/components/schemas/SeparateEmploymentRecordCommand'", + "$ref: '#/components/schemas/EmploymentSeparationResult'", + "$ref: '#/components/responses/SeparationConflict'", + "$ref: '#/components/responses/RecordNotFound'", + "$ref: '#/components/responses/PayloadTooLarge'", + "$ref: '#/components/responses/UnsupportedMediaType'", + "$ref: '#/components/responses/InternalError'", + ): + with self.subTest(fragment=fragment): + self.assertIn(fragment, block) + self.assertIn(" '200':", block) + self.assertNotIn(" Location:", block) + + def test_request_schema_matches_application_command(self) -> None: + block = _yaml_block(self.document, " SeparateEmploymentRecordCommand:") + for field_name in ( + "person_record_id", + "employment_record_id", + "expected_employment_record_version_id", + "separation_effective_on", + "separation_reason_code", + "evidence_reference", + "evidence_version_code", + "confirmation_reference", + ): + with self.subTest(field_name=field_name): + self.assertIn(f" - {field_name}", block) + self.assertIn(f" {field_name}:", block) + self.assertIn(" additionalProperties: false", block) + self.assertIn("pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$'", block) + self.assertIn("pattern: '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$'", block) + self.assertIn("pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$'", block) + + def test_result_schema_exposes_only_first_committed_evidence(self) -> None: + block = _yaml_block(self.document, " EmploymentSeparationResult:") + for field_name in ( + "employment_record_id", + "separated_employment_record_version_id", + "recorded_at", + "replayed", + ): + with self.subTest(field_name=field_name): + self.assertIn(f" - {field_name}", block) + self.assertIn(f" {field_name}:", block) + self.assertIn(" additionalProperties: false", block) + + def test_conflict_response_covers_state_and_idempotency_conflicts(self) -> None: + block = _yaml_block(self.document, " SeparationConflict:") + self.assertIn("current Employment or Assignment state", block) + self.assertIn("expected version", block) + self.assertIn("idempotency key", block) + self.assertIn("$ref: '#/components/schemas/ErrorResponse'", block) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 1e1b9aab58c536f85f968ee01e9d3f1f8bf4faf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:18:28 +0900 Subject: [PATCH 174/269] fix(people): publish separation state conflict semantics --- schemas/openapi.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index b5d9ba070..14ada0b6e 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -215,7 +215,7 @@ paths: '404': $ref: '#/components/responses/RecordNotFound' '409': - $ref: '#/components/responses/IdempotencyConflict' + $ref: '#/components/responses/SeparationConflict' '413': $ref: '#/components/responses/PayloadTooLarge' '415': @@ -1100,6 +1100,12 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + SeparationConflict: + description: The separation conflicts with current Employment or Assignment state, the expected version, or prior semantics bound to the idempotency key. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' InsufficientEvidence: description: The high-impact command lacks sufficient versioned evidence. content: From 872230ea83b7bde7e53dec175e52ca4d16b205b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:38:06 +0900 Subject: [PATCH 175/269] fix(people): reseal separation OpenAPI manifest --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index ac1648354..03a87d16a 100644 --- a/manifest.json +++ b/manifest.json @@ -354,8 +354,8 @@ { "path": "schemas/openapi.yaml", "sha256": "c37522504d1f6ac6410eaac833dddbf09aacc85572da38a1cf7539541833ea8e", - "bytes": 29511, - "lines": 1020 + "bytes": 33668, + "lines": 1150 }, { "path": "scripts/foundation-contract-core.mjs", From 36502fb86616208d562cd76020ea04d50092ffa1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:40:54 +0900 Subject: [PATCH 176/269] fix(people): use exact OpenAPI manifest provenance --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 03a87d16a..a4540c582 100644 --- a/manifest.json +++ b/manifest.json @@ -353,9 +353,9 @@ }, { "path": "schemas/openapi.yaml", - "sha256": "c37522504d1f6ac6410eaac833dddbf09aacc85572da38a1cf7539541833ea8e", + "sha256": "e73da7c826f9721853287f9515454c545c8128e63ab3517512d19a3c5dbcd85f", "bytes": 33668, - "lines": 1150 + "lines": 1144 }, { "path": "scripts/foundation-contract-core.mjs", From 2206e4719c3fe561f3e49b37a95bf4eaa5c58adf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:42:17 +0900 Subject: [PATCH 177/269] fix(ci): remove deprecated setup-node runtime --- .github/workflows/foundation-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/foundation-ci.yml b/.github/workflows/foundation-ci.yml index 6b475d6f2..1b5b9d64a 100644 --- a/.github/workflows/foundation-ci.yml +++ b/.github/workflows/foundation-ci.yml @@ -39,7 +39,7 @@ jobs: python-version: "3.14" check-latest: false - name: Set up Node.js LTS - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" check-latest: false From 2818fa269609a499b597da4f862a696a917ba669 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:43:17 +0900 Subject: [PATCH 178/269] fix(ci): reseal Foundation workflow after Node runtime update --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index a4540c582..752a8c3f3 100644 --- a/manifest.json +++ b/manifest.json @@ -5,7 +5,7 @@ "files": [ { "path": ".github/workflows/foundation-ci.yml", - "sha256": "b6a4365936b66803a8112f034c77d53d33301a7a798ed4f68746a4f2d8b081d7", + "sha256": "1c84b084884679eaf631d1cab49d42a0c5b3852170402fced3dfd9fee9cafd47", "bytes": 6651, "lines": 125 }, From 881fdba7cb62a9afa31555bd86ffdf160324bd80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:46:16 +0900 Subject: [PATCH 179/269] fix(validation): allow PostgreSQL public schema qualification --- tests/validate_repository.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/validate_repository.py b/tests/validate_repository.py index d9d4c15a3..717e445e0 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -226,12 +226,19 @@ def _validate_database_contract() -> None: _fail("No CREATE TABLE statement found") for match in matches: - for identifier in filter(None, (match.group("schema"), match.group("table"))): - if "_" not in identifier or identifier != identifier.lower(): + schema_name = match.group("schema") + if schema_name is not None and schema_name != "public": + if "_" not in schema_name or schema_name != schema_name.lower(): _fail( - "Database object name is not two-word lowercase snake_case: " - f"{identifier}" + "Database schema name is not two-word lowercase snake_case: " + f"{schema_name}" ) + table_name = match.group("table") + if "_" not in table_name or table_name != table_name.lower(): + _fail( + "Database table name is not two-word lowercase snake_case: " + f"{table_name}" + ) for guard in ( "effective_to IS NULL OR effective_to > effective_from", From e999f31fdb78c1c62e1ec503dd395c2a4ff538c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:49:04 +0900 Subject: [PATCH 180/269] fix(validation): reseal naming contract provenance --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 752a8c3f3..920438874 100644 --- a/manifest.json +++ b/manifest.json @@ -467,9 +467,9 @@ }, { "path": "tests/validate_repository.py", - "sha256": "091836b2f68600a30b08f7da2cea8b3bef10201a123da720a7369bf10985eec2", - "bytes": 27237, - "lines": 637 + "sha256": "709cb7298ca6dfce3b2a4b0199c390e9d87227560085874bf3738a5827096a26", + "bytes": 27516, + "lines": 644 } ] } From a726479f8717f1eb4b70253769164c858e17cf52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:50:54 +0900 Subject: [PATCH 181/269] fix(validation): accept qualified FORCE RLS owner tables --- tests/validate_repository.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/validate_repository.py b/tests/validate_repository.py index 717e445e0..d4136f3f8 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -386,7 +386,10 @@ def _validate_database_contract() -> None: table_block = table_sql[block_start:block_end] if "tenant_record_id uuid NOT NULL" not in table_block: _fail(f"Tenant binding is missing from table: {table_name}") - if f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY" not in sql: + if ( + f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY" not in sql + and f"ALTER TABLE public.{table_name} FORCE ROW LEVEL SECURITY" not in sql + ): _fail(f"Forced row-level security is missing from table: {table_name}") if len(tenant_matches) != len(matches) - 1: From efd94d79f5d42433e92b3c54d6fcb7a7f620a27d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:52:20 +0900 Subject: [PATCH 182/269] fix(validation): reseal qualified RLS validator --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 920438874..bd041ece2 100644 --- a/manifest.json +++ b/manifest.json @@ -467,9 +467,9 @@ }, { "path": "tests/validate_repository.py", - "sha256": "709cb7298ca6dfce3b2a4b0199c390e9d87227560085874bf3738a5827096a26", - "bytes": 27516, - "lines": 644 + "sha256": "dceb03e0b6a428fc62021ceca7fa6d545593545b53f642f7617cd2323866d658", + "bytes": 27627, + "lines": 647 } ] } From 607ef0ec67c1998af5246d3a53eb799ebc3c8b1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:53:23 +0900 Subject: [PATCH 183/269] fix(foundation): seal separation validation contracts --- CHANGELOG.md | 1 + manifest.json | 56 +++++++++++++++++++++------- scripts/foundation-contract-core.mjs | 5 +++ tests/openapi-contract.test.mjs | 21 +++++++++-- tests/validate_repository.py | 15 ++++++-- 5 files changed, 78 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d5b3e55..f9670ff0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ All notable changes to Orgmetra will be documented in this file. ### Changed +- Foundation database validation now treats PostgreSQL-owned `public` as an infrastructure qualifier, continues enforcing multiword naming for organization-owned schemas and created tables, and recognizes the optional qualifier on forced row-level-security declarations. - Consolidated repository-owned PR validation from twelve workflows into one Foundation CI job, while keeping the dual-cluster recovery rehearsal separately path-scoped. Central required review and security workflows remain organization-owned. - New predictive-validity membership must use one normalized worker-level case; the three independent validity-study decision/evidence/outcome link relations are historical read surfaces only and can no longer accept new rows. A case insert also rejects a criterion observation whose recorded interval is already closed at `linked_at`. - Canonicalized service identifiers as two-or-more-word `snake_case` across architecture, deployment, ACL, metrics, and client contracts. diff --git a/manifest.json b/manifest.json index bd041ece2..ebc8395e3 100644 --- a/manifest.json +++ b/manifest.json @@ -5,7 +5,7 @@ "files": [ { "path": ".github/workflows/foundation-ci.yml", - "sha256": "1c84b084884679eaf631d1cab49d42a0c5b3852170402fced3dfd9fee9cafd47", + "sha256": "b6a4365936b66803a8112f034c77d53d33301a7a798ed4f68746a4f2d8b081d7", "bytes": 6651, "lines": 125 }, @@ -29,9 +29,9 @@ }, { "path": "CHANGELOG.md", - "sha256": "9ad6dad273c94c30741522ca87205ff24eb92c53becc8b53739d93acb28126f9", - "bytes": 17697, - "lines": 78 + "sha256": "613702432c44c9e198371951d13fd70324617947fa87594c9d6aaf57bdc9ef72", + "bytes": 17968, + "lines": 79 }, { "path": "CLAUDE.md", @@ -135,6 +135,24 @@ "bytes": 12713, "lines": 260 }, + { + "path": "database/migrations/0014_employment_separation_transition.sql", + "sha256": "e24ffddb03fae5d9a2be859656860b58054eb3453a156eec3b402509797dfdc2", + "bytes": 21465, + "lines": 523 + }, + { + "path": "database/migrations/0015_employment_separation_capability_hardening.sql", + "sha256": "8c08d2f50fd479f895956865d421646d1fa9a56485868f569548cf953306df05", + "bytes": 843, + "lines": 32 + }, + { + "path": "database/migrations/0016_employment_separation_executor_capability.sql", + "sha256": "41c3528aefe1c1dd7e8abed55bdfccf450fc4372750176137a466a8769f12138", + "bytes": 5372, + "lines": 179 + }, { "path": "docs/API_CONTRACT.md", "sha256": "63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589", @@ -359,9 +377,9 @@ }, { "path": "scripts/foundation-contract-core.mjs", - "sha256": "9b03efbbdffa60a05f5924e8a61b1cbc3cd75c502df428a5920085e8d0bf3603", - "bytes": 28121, - "lines": 688 + "sha256": "ee8d988efe848e57f4fbaf30112d262a84e9e230fe8e8d41ece6b525943b19c2", + "bytes": 28452, + "lines": 693 }, { "path": "scripts/foundation-contract.mjs", @@ -383,9 +401,9 @@ }, { "path": "tests/openapi-contract.test.mjs", - "sha256": "80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc", - "bytes": 6438, - "lines": 195 + "sha256": "0a8f459d3f058ac70cded5d42db4108e7bb0a065d26b58b84e7b985101f4b06b", + "bytes": 7236, + "lines": 210 }, { "path": "tests/test_audit_outbox_hardening_postgres.sh", @@ -417,6 +435,18 @@ "bytes": 17811, "lines": 469 }, + { + "path": "tests/test_employment_separation_capability_postgres.sh", + "sha256": "c80ed620aabdf59d4353cda2efb5ed9b38d193aabbaf9356774bddd12e31b182", + "bytes": 9400, + "lines": 241 + }, + { + "path": "tests/test_employment_separation_postgres.sh", + "sha256": "703a7080f241ee3e4961d391c38365b5e4080b561d5f53dbedf4ffffb182dbca", + "bytes": 20835, + "lines": 536 + }, { "path": "tests/test_evidence_sealing_postgres.sh", "sha256": "57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7", @@ -467,9 +497,9 @@ }, { "path": "tests/validate_repository.py", - "sha256": "dceb03e0b6a428fc62021ceca7fa6d545593545b53f642f7617cd2323866d658", - "bytes": 27627, - "lines": 647 + "sha256": "e6dd1cd0684f9e4b4118335ae353aeba6fe205e414080eccb6a6ffce52fb36a1", + "bytes": 28048, + "lines": 654 } ] } diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 4aacefb3c..8830fe229 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -67,6 +67,9 @@ export const REQUIRED_FILES = Object.freeze([ 'database/migrations/0011_criterion_observation_scope.sql', 'database/migrations/0012_people_mutation_idempotency.sql', 'database/migrations/0013_job_analysis_snapshot.sql', + 'database/migrations/0014_employment_separation_transition.sql', + 'database/migrations/0015_employment_separation_capability_hardening.sql', + 'database/migrations/0016_employment_separation_executor_capability.sql', 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', 'packages/hris-kernel/tests/test_audit_outbox.py', 'schemas/openapi.yaml', @@ -88,6 +91,8 @@ export const REQUIRED_FILES = Object.freeze([ 'tests/test_criterion_observation_scope_postgres.sh', 'tests/test_people_mutation_idempotency_postgres.sh', 'tests/test_job_analysis_snapshot_postgres.sh', + 'tests/test_employment_separation_postgres.sh', + 'tests/test_employment_separation_capability_postgres.sh', 'tests/validate_repository.py' ]); diff --git a/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 8e98078da..d2d629847 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -15,6 +15,19 @@ function removeOccurrence(text, fragment, occurrence = 1) { return text.slice(0, searchIndex) + text.slice(searchIndex + fragment.length); } +function removeFromBlock(text, blockMarker, fragment) { + const blockStart = text.indexOf(blockMarker); + assert.ok(blockStart >= 0, `fixture block missing: ${blockMarker}`); + const indentation = blockMarker.match(/^ */)[0].length; + const nextPeer = new RegExp(`^ {${indentation}}\\S`, 'gm'); + nextPeer.lastIndex = blockStart + blockMarker.length; + const nextMatch = nextPeer.exec(text); + const blockEnd = nextMatch?.index ?? text.length; + const block = text.slice(blockStart, blockEnd); + const mutatedBlock = removeOccurrence(block, fragment); + return text.slice(0, blockStart) + mutatedBlock + text.slice(blockEnd); +} + test('canonical OpenAPI passes structural operation validation', () => { assert.deepEqual(validateOpenApiContract(canonical), []); }); @@ -151,8 +164,8 @@ for (const testCase of [ }, { name: 'createAssignmentRecord scope', + blockMarker: ' /assignment-records:\n', fragment: ' - orgmetra.people.write\n', - occurrence: 3, expected: /createAssignmentRecord.*scope/ }, { @@ -163,13 +176,15 @@ for (const testCase of [ }, { name: 'assignment confirmation requirement', + blockMarker: ' CreateAssignmentRecordCommand:\n', fragment: ' - confirmation_reference\n', - occurrence: 5, expected: /CreateAssignmentRecordCommand.*confirmation/ } ]) { test(`structural OpenAPI gate rejects missing ${testCase.name}`, () => { - const mutated = removeOccurrence(canonical, testCase.fragment, testCase.occurrence ?? 1); + const mutated = testCase.blockMarker + ? removeFromBlock(canonical, testCase.blockMarker, testCase.fragment) + : removeOccurrence(canonical, testCase.fragment, testCase.occurrence ?? 1); const errors = validateOpenApiContract(mutated); assert.ok(errors.some((error) => testCase.expected.test(error)), errors.join('\n')); }); diff --git a/tests/validate_repository.py b/tests/validate_repository.py index d4136f3f8..f37049c85 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -70,6 +70,9 @@ "database/migrations/0011_criterion_observation_scope.sql", "database/migrations/0012_people_mutation_idempotency.sql", "database/migrations/0013_job_analysis_snapshot.sql", + "database/migrations/0014_employment_separation_transition.sql", + "database/migrations/0015_employment_separation_capability_hardening.sql", + "database/migrations/0016_employment_separation_executor_capability.sql", "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", "packages/hris-kernel/tests/test_audit_outbox.py", "schemas/openapi.yaml", @@ -91,6 +94,8 @@ "tests/test_criterion_observation_scope_postgres.sh", "tests/test_people_mutation_idempotency_postgres.sh", "tests/test_job_analysis_snapshot_postgres.sh", + "tests/test_employment_separation_postgres.sh", + "tests/test_employment_separation_capability_postgres.sh", "tests/validate_repository.py", ] @@ -386,10 +391,12 @@ def _validate_database_contract() -> None: table_block = table_sql[block_start:block_end] if "tenant_record_id uuid NOT NULL" not in table_block: _fail(f"Tenant binding is missing from table: {table_name}") - if ( - f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY" not in sql - and f"ALTER TABLE public.{table_name} FORCE ROW LEVEL SECURITY" not in sql - ): + force_rls_pattern = re.compile( + rf"\bALTER\s+TABLE\s+(?:[a-z_][a-z0-9_]*\.)?" + rf"{re.escape(table_name)}\s+FORCE\s+ROW\s+LEVEL\s+SECURITY\b", + flags=re.IGNORECASE, + ) + if force_rls_pattern.search(sql) is None: _fail(f"Forced row-level security is missing from table: {table_name}") if len(tenant_matches) != len(matches) - 1: From b86c831388521c0cd99e35e90adfb254cc4731e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 00:55:22 +0900 Subject: [PATCH 184/269] fix(foundation): reseal exact workflow provenance --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index ebc8395e3..6de0c15f8 100644 --- a/manifest.json +++ b/manifest.json @@ -5,7 +5,7 @@ "files": [ { "path": ".github/workflows/foundation-ci.yml", - "sha256": "b6a4365936b66803a8112f034c77d53d33301a7a798ed4f68746a4f2d8b081d7", + "sha256": "1c84b084884679eaf631d1cab49d42a0c5b3852170402fced3dfd9fee9cafd47", "bytes": 6651, "lines": 125 }, From 0e6e33221913dad94c40f1f108abb6adcfacca36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 01:01:15 +0900 Subject: [PATCH 185/269] test(people): construct governed denial evidence --- .../tests/test_employment_separation_http.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_employment_separation_http.py b/services/people-api/tests/test_employment_separation_http.py index e4b770c0c..e3d63320b 100644 --- a/services/people-api/tests/test_employment_separation_http.py +++ b/services/people-api/tests/test_employment_separation_http.py @@ -8,7 +8,11 @@ import unittest from uuid import UUID -from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy +from orgmetra_keyverse_adapter import ( + AuthorizationDecision, + AuthorizationDeniedError, + PurposeBoundAccessPolicy, +) from orgmetra_people_api import AuthenticatedPrincipal, AuthenticationFailed from orgmetra_people_api.separation import ( EmploymentSeparationCommand, @@ -257,7 +261,23 @@ async def test_exact_body_and_domain_failures_are_client_safe(self) -> None: self.assertEqual(status, 400) self.assertEqual(payload["error_code"], "invalid_request") - denied_port = RecordingSeparationPort(error=AuthorizationDeniedError("denied")) + denied_decision = AuthorizationDecision( + allowed=False, + tenant_record_id=TENANT, + actor_reference="keyverse_subject:people-operator-17", + resource_reference=f"employment_record:{EMPLOYMENT}", + policy_version_code="people-access-v1", + purpose_code="workforce_admin", + operation_code="employment_separation", + resource_kind="employment_record", + requested_fields=frozenset(), + authorized_fields=frozenset(), + reason_code="denied", + next_action="Request workforce administrator access.", + ) + denied_port = RecordingSeparationPort( + error=AuthorizationDeniedError(denied_decision) + ) status, _, payload = await self._request(self._app(separation_port=denied_port)) self.assertEqual(status, 403) self.assertEqual(payload["error_code"], "access_denied") From 4ba75c0240edda8417d17c904c8a8131473254d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 01:01:23 +0900 Subject: [PATCH 186/269] test(people): require separation result validation before commit --- .../test_postgres_employment_separation.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/services/people-api/tests/test_postgres_employment_separation.py b/services/people-api/tests/test_postgres_employment_separation.py index 461728e0b..89ead3dd3 100644 --- a/services/people-api/tests/test_postgres_employment_separation.py +++ b/services/people-api/tests/test_postgres_employment_separation.py @@ -105,15 +105,17 @@ def fetchmany(self, size: int) -> object: class FakeConnection(AbstractContextManager["FakeConnection"]): - """Expose one cursor through the DB-API context-manager shape.""" + """Expose one cursor and capture whether the transaction exits with an error.""" def __init__(self, cursor: FakeCursor) -> None: self._cursor = cursor + self.exit_exception_type: object | None = None def __enter__(self) -> "FakeConnection": return self def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.exit_exception_type = exc_type return None def cursor(self) -> FakeCursor: @@ -137,12 +139,14 @@ class PostgresEmploymentSeparationTests(unittest.TestCase): def test_calls_governed_function_in_one_tenant_bound_transaction(self) -> None: cursor = FakeCursor(row=(EMPLOYMENT, TERMINAL_VERSION, RECORDED_AT, False)) - factory = ConnectionFactory(FakeConnection(cursor)) + connection = FakeConnection(cursor) + factory = ConnectionFactory(connection) port = PostgresEmploymentSeparationPort(factory) result = port.separate_employment(command=command(), authorization=authorization()) self.assertEqual(factory.calls, 1) + self.assertIsNone(connection.exit_exception_type) self.assertEqual(result.employment_record_id, EMPLOYMENT) self.assertEqual(result.separated_employment_record_version_id, TERMINAL_VERSION) self.assertEqual(result.recorded_at, RECORDED_AT) @@ -188,7 +192,7 @@ def test_rejects_authorization_that_does_not_match_exact_operation(self) -> None with self.subTest(decision=decision), self.assertRaises(EmploymentSeparationIntegrityError): port.separate_employment(command=command(), authorization=decision) # type: ignore[arg-type] - def test_rejects_malformed_database_result(self) -> None: + def test_rejects_malformed_database_result_before_transaction_commit_boundary(self) -> None: rows = ( None, "not-a-row", @@ -200,13 +204,17 @@ def test_rejects_malformed_database_result(self) -> None: (UUID("0198a412-8000-7000-8000-000000000099"), TERMINAL_VERSION, RECORDED_AT, False), ) for row in rows: + connection = FakeConnection(FakeCursor(row=row)) with self.subTest(row=row), self.assertRaises(EmploymentSeparationIntegrityError): - port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(FakeCursor(row=row)))) + port = PostgresEmploymentSeparationPort(ConnectionFactory(connection)) port.separate_employment(command=command(), authorization=authorization()) + self.assertIs(connection.exit_exception_type, EmploymentSeparationIntegrityError) + connection = FakeConnection(InvalidBatchCursor()) with self.assertRaises(EmploymentSeparationIntegrityError): - port = PostgresEmploymentSeparationPort(ConnectionFactory(FakeConnection(InvalidBatchCursor()))) + port = PostgresEmploymentSeparationPort(ConnectionFactory(connection)) port.separate_employment(command=command(), authorization=authorization()) + self.assertIs(connection.exit_exception_type, EmploymentSeparationIntegrityError) def test_maps_governed_conflicts_but_not_permission_failures(self) -> None: cases = ( From 1e69528a659ccc0e015537de82fd28c213d58d29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 01:01:41 +0900 Subject: [PATCH 187/269] fix(people): validate separation receipt before commit --- .../postgres_separation.py | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_separation.py b/services/people-api/src/orgmetra_people_api/postgres_separation.py index bb99bce9d..e5c381a81 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_separation.py +++ b/services/people-api/src/orgmetra_people_api/postgres_separation.py @@ -65,6 +65,26 @@ def _one_result_row(cursor: Any) -> tuple[object, object, object, object]: return row[0], row[1], row[2], row[3] +def _validated_result( + *, + row: tuple[object, object, object, object], + expected_employment_record_id: object, +) -> EmploymentSeparationResult: + """Validate persistence evidence while the transaction can still roll back.""" + try: + result = EmploymentSeparationResult( + employment_record_id=row[0], # type: ignore[arg-type] + separated_employment_record_version_id=row[1], # type: ignore[arg-type] + recorded_at=row[2], # type: ignore[arg-type] + replayed=row[3], # type: ignore[arg-type] + ) + except (TypeError, ValueError) as error: + raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") from error + if result.employment_record_id != expected_employment_record_id: + raise EmploymentSeparationIntegrityError("Employment separation database identity does not match command") + return result + + def _translate_database_error(error: Exception) -> NoReturn: """Translate only reviewed business SQLSTATEs; permission failures remain operational errors.""" sqlstate = getattr(error, "sqlstate", None) @@ -133,18 +153,11 @@ def separate_employment( ), ) row = _one_result_row(cursor) + result = _validated_result( + row=row, + expected_employment_record_id=detached_command.employment_record_id, + ) except Exception as error: _translate_database_error(error) - try: - result = EmploymentSeparationResult( - employment_record_id=row[0], # type: ignore[arg-type] - separated_employment_record_version_id=row[1], # type: ignore[arg-type] - recorded_at=row[2], # type: ignore[arg-type] - replayed=row[3], # type: ignore[arg-type] - ) - except (TypeError, ValueError) as error: - raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") from error - if result.employment_record_id != detached_command.employment_record_id: - raise EmploymentSeparationIntegrityError("Employment separation database identity does not match command") return result From cd920715c0b3db67223b6bef8df622ca38caca31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 01:02:54 +0900 Subject: [PATCH 188/269] docs(people): trace separation commit-boundary validation --- docs/traceability/employment-separation.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index 1d74a0403..63478dbbf 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -15,7 +15,8 @@ This trace binds issue #314 to the first authoritative Employment separation sli | High-impact governance evidence | ADR 0006 + ADR 0008 + ADR 0015 | controlled reason, evidence reference/version, actor, `workforce_admin` purpose and human confirmation are mandatory; audit/outbox is persisted in the same transaction | implemented_on_active_pr | | Retry safety | People mutation idempotency contract | exact tenant+route+idempotency-key advisory lock; same semantic command replays first terminal version/timestamp; changed command under same key fails | implemented_on_active_pr | | Purpose-bound application authorization | Keyverse adapter + ADR 0015 | `separation.py` authorizes the exact Employment, tenant, `workforce_admin` purpose, `separate_record` operation and `orgmetra.people.write` scope before the persistence port; command and result identities are detached and rebound | implemented_on_active_pr | -| Governed persistence adapter | ADR 0015 | `PostgresEmploymentSeparationPort` starts one READ COMMITTED read/write transaction, binds tenant context, invokes only `separate_employment_record_once(...)`, validates one typed DB result, maps reviewed domain SQLSTATEs, and leaves permission failures operational | implemented_on_active_pr | +| Governed persistence adapter | ADR 0015 | `PostgresEmploymentSeparationPort` starts one READ COMMITTED read/write transaction, binds tenant context, invokes only `separate_employment_record_once(...)`, validates one typed DB result before the connection context may commit, maps reviewed domain SQLSTATEs, and leaves permission failures operational | implemented_on_active_pr | +| Commit-boundary result integrity | #314 | `test_postgres_employment_separation.py` requires malformed/mismatched DB evidence to leave the connection context with `EmploymentSeparationIntegrityError`, so a driver/result-integrity failure rolls back rather than becoming a committed write followed by an application-only failure | implemented_on_active_pr | | Deny-default SQL capability | ADR 0015 | migrations `0015`/`0016` revoke PUBLIC, create separate `NOLOGIN`/`NOBYPASSRLS` owner and executor roles, move the function to the owner as SECURITY DEFINER, revoke temporary schema CREATE, and grant the executor function EXECUTE only | implemented_on_active_pr | | No direct DML bypass from runtime capability | ADR 0015 | `tests/test_employment_separation_capability_postgres.sh` requires the executor to have no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE rights on governed People/audit/outbox relations while still crossing the function boundary | awaiting_foundation_registration | | Capability-test failure isolation | #314 | the unrelated probe role uses collision-resistant per-execution identity; failure cleanup is best-effort without masking the causal error, while nominal success requires strict verified role cleanup | implemented_on_active_pr | @@ -23,14 +24,15 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | | Canonical CI ownership | #311 | both focused PostgreSQL contracts must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | | Buyer HTTP boundary | #314 | `EmploymentSeparationAsgiApp` authenticates and binds tenant/actor before the body is accepted, requires `workforce_admin`, preserves expected-version/date/reason/evidence/confirmation/idempotency semantics, generates server-owned audit/outbox identities, returns replay plus DB-owned recorded time, and sanitizes backend failures | implemented_on_active_pr | -| Canonical OpenAPI and buyer performance journey | #314 | `schemas/openapi.yaml` must publish the separation request/result/error contract and the exact route must obtain buyer-path p95/E2E evidence without exposing owner-role or direct-DML capabilities | planned | +| Canonical OpenAPI route contract | #314 | `schemas/openapi.yaml` publishes `POST /v1/employment-separations`; focused route/schema regression verifies the exact request/result/error surface | implemented_on_active_pr | +| Buyer-path performance journey | #314 | the published route still needs realistic async E2E/k6 measurement against the operational persistence capability before any p95 claim is made | planned | | Rehire | #302 | existing Person + new Employment must cite authoritative prior separation and fresh rehire authority | planned | ## Current acceptance boundary -`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015, the application/persistence ports, the buyer-facing ASGI route, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. +`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015, the application/persistence ports, the buyer-facing ASGI route, canonical OpenAPI contract, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. -`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision and issues no direct People or audit/outbox DML. `separation_http.py` is the buyer request edge: it binds authentication, tenant, actor, purpose, exact command fields and client-safe error semantics before delegating the synchronous persistence operation off the ASGI event loop. Server-generated audit/outbox identity failure is an operational failure and is not misclassified as invalid buyer input. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. +`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision, issues no direct People or audit/outbox DML, and validates returned persistence evidence before transaction-context exit so rejected evidence can still force rollback. `separation_http.py` is the buyer request edge: it binds authentication, tenant, actor, purpose, exact command fields and client-safe error semantics before delegating the synchronous persistence operation off the ASGI event loop. Server-generated audit/outbox identity failure is an operational failure and is not misclassified as invalid buyer input. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. The existing Foundation workflow remains owned by its canonical Foundation stack. The new PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. @@ -40,6 +42,6 @@ Foundation reconciliation should discover `tests/test_employment_separation_post The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. -The remaining buyer-visible gap is to publish the already-implemented HTTP command in the canonical OpenAPI contract, add exact schema/route regression coverage, and then measure the route under the buyer-path performance/E2E lane. No performance claim is made from unit tests or queued CI alone. +The remaining buyer-visible route gap is realistic buyer-path p95/E2E evidence under the deployed executor capability. No performance claim is made from unit tests, static schema checks, or queued CI alone. Once those owner boundaries are reconciled and exact-head hosted evidence is green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From c8d1c3993ce1eb4e8bdabfe4b666c61eda57fcff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 01:06:55 +0900 Subject: [PATCH 189/269] test(people): cover separation HTTP failure boundaries --- .../tests/test_employment_separation_http.py | 89 ++++++++++++++++++- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/services/people-api/tests/test_employment_separation_http.py b/services/people-api/tests/test_employment_separation_http.py index e3d63320b..9f73479f6 100644 --- a/services/people-api/tests/test_employment_separation_http.py +++ b/services/people-api/tests/test_employment_separation_http.py @@ -20,6 +20,7 @@ EmploymentSeparationResult, ) from orgmetra_people_api.separation_http import EmploymentSeparationAsgiApp +from orgmetra_people_api.mutations import PeopleMutationNotFound TENANT = UUID("0198a412-9000-7000-8000-000000000001") PERSON = UUID("0198a412-9000-7000-8000-000000000020") @@ -64,12 +65,12 @@ def request_body(**overrides: object) -> bytes: class FakeAuthenticator: """Return one principal while retaining no bearer-token material in results.""" - def __init__(self, principal: AuthenticatedPrincipal, *, error: Exception | None = None) -> None: + def __init__(self, principal: object, *, error: Exception | None = None) -> None: self.principal = principal self.error = error self.tokens: list[str] = [] - async def authenticate(self, bearer_token: str) -> AuthenticatedPrincipal: + async def authenticate(self, bearer_token: str) -> object: self.tokens.append(bearer_token) if self.error is not None: raise self.error @@ -214,6 +215,16 @@ async def test_success_returns_database_owned_terminal_version_and_replay_eviden self.assertEqual(command.idempotency_key, IDEMPOTENCY_KEY.decode("ascii")) self.assertEqual(authorization.resource_reference, f"employment_record:{EMPLOYMENT.hex}") + async def test_non_http_scope_is_rejected_before_receive(self) -> None: + async def receive() -> dict[str, object]: + raise AssertionError("non-HTTP scope must not read a request body") + + async def send(message: dict[str, object]) -> None: + raise AssertionError(f"non-HTTP scope must not send a response: {message!r}") + + with self.assertRaisesRegex(ValueError, "only HTTP"): + await self._app()({"type": "lifespan"}, receive, send) + async def test_route_method_and_purpose_fail_without_persistence(self) -> None: port = RecordingSeparationPort() app = self._app(separation_port=port) @@ -229,6 +240,18 @@ async def test_route_method_and_purpose_fail_without_persistence(self) -> None: self.assertIn("error_code", payload) self.assertEqual(port.calls, []) + async def test_command_header_failures_are_bounded_before_authentication(self) -> None: + cases: tuple[tuple[list[tuple[bytes, bytes]], int], ...] = ( + ([item for item in valid_headers() if item[0] != b"content-type"], 415), + ([(name, b"text/plain") if name == b"content-type" else (name, value) for name, value in valid_headers()], 415), + ([(name, b"not namespaced") if name == b"x-actor-reference" else (name, value) for name, value in valid_headers()], 400), + ) + for headers, expected in cases: + with self.subTest(expected=expected): + status, _, payload = await self._request(self._app(), headers=headers) + self.assertEqual(status, expected) + self.assertIn("error_code", payload) + async def test_authentication_and_tenant_actor_binding_fail_closed(self) -> None: denied = self._app( authenticator=FakeAuthenticator(self.principal, error=AuthenticationFailed("bad token")) @@ -248,11 +271,47 @@ async def test_authentication_and_tenant_actor_binding_fail_closed(self) -> None self.assertEqual(status, 403) self.assertEqual(payload["error_code"], "access_denied") + other_actor = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:other-operator", + granted_scope_codes=self.principal.granted_scope_codes, + ) + status, _, payload = await self._request( + self._app(authenticator=FakeAuthenticator(other_actor)) + ) + self.assertEqual(status, 403) + self.assertEqual(payload["error_code"], "access_denied") + + async def test_authentication_dependency_and_invalid_principal_are_internal_errors(self) -> None: + cases = ( + FakeAuthenticator(self.principal, error=RuntimeError("identity backend offline")), + FakeAuthenticator(object()), + ) + for authenticator in cases: + with self.subTest(authenticator=authenticator): + status, _, payload = await self._request(self._app(authenticator=authenticator)) + self.assertEqual(status, 500) + self.assertEqual(payload["error_code"], "internal_error") + self.assertNotIn("identity backend offline", json.dumps(payload)) + + async def test_body_reader_rejects_oversize_and_invalid_json(self) -> None: + status, _, payload = await self._request(self._app(), body=b"x" * 65537) + self.assertEqual(status, 413) + self.assertEqual(payload["error_code"], "payload_too_large") + + status, _, payload = await self._request(self._app(), body=b"{") + self.assertEqual(status, 400) + self.assertEqual(payload["error_code"], "invalid_request") + async def test_exact_body_and_domain_failures_are_client_safe(self) -> None: malformed_cases = ( request_body(extra="forbidden"), + request_body(person_record_id=17), + request_body(person_record_id="00000000-0000-0000-0000-000000000000"), request_body(employment_record_id="not-a-uuid"), + request_body(separation_effective_on=20261001), request_body(separation_effective_on="2026-10-01T00:00:00Z"), + request_body(separation_effective_on="2026-02-30"), request_body(separation_reason_code="Voluntary resignation"), ) for body in malformed_cases: @@ -282,12 +341,24 @@ async def test_exact_body_and_domain_failures_are_client_safe(self) -> None: self.assertEqual(status, 403) self.assertEqual(payload["error_code"], "access_denied") + not_found_port = RecordingSeparationPort(error=PeopleMutationNotFound("sensitive missing detail")) + status, _, payload = await self._request(self._app(separation_port=not_found_port)) + self.assertEqual(status, 404) + self.assertEqual(payload["error_code"], "record_not_found") + self.assertNotIn("sensitive missing detail", json.dumps(payload)) + conflict_port = RecordingSeparationPort(error=EmploymentSeparationIntegrityError("sensitive backend detail")) status, _, payload = await self._request(self._app(separation_port=conflict_port)) self.assertEqual(status, 409) self.assertEqual(payload["error_code"], "separation_conflict") self.assertNotIn("sensitive backend detail", json.dumps(payload)) + failure_port = RecordingSeparationPort(error=RuntimeError("database credential leaked")) + status, _, payload = await self._request(self._app(separation_port=failure_port)) + self.assertEqual(status, 500) + self.assertEqual(payload["error_code"], "internal_error") + self.assertNotIn("database credential leaked", json.dumps(payload)) + async def test_server_generated_identity_failure_is_internal_not_client_error(self) -> None: port = RecordingSeparationPort() @@ -297,12 +368,24 @@ def unavailable_id_factory() -> UUID: status, _, payload = await self._request( self._app(separation_port=port, id_factory=unavailable_id_factory) ) - self.assertEqual(status, 500) self.assertEqual(payload["error_code"], "internal_error") self.assertNotIn("entropy source unavailable", json.dumps(payload)) self.assertEqual(port.calls, []) + invalid_values: tuple[object, ...] = (object(), UUID(int=0)) + for invalid_value in invalid_values: + with self.subTest(invalid_value=invalid_value): + status, _, payload = await self._request( + self._app( + separation_port=port, + id_factory=lambda invalid_value=invalid_value: invalid_value, # type: ignore[arg-type,return-value] + ) + ) + self.assertEqual(status, 500) + self.assertEqual(payload["error_code"], "internal_error") + self.assertEqual(port.calls, []) + if __name__ == "__main__": # pragma: no cover unittest.main() From 01ca8e8321bd70a21a583656f26613c7621747f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:04:15 +0900 Subject: [PATCH 190/269] test(people): classify malformed separation receipts as internal errors --- ...st_employment_separation_integrity_http.py | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 services/people-api/tests/test_employment_separation_integrity_http.py diff --git a/services/people-api/tests/test_employment_separation_integrity_http.py b/services/people-api/tests/test_employment_separation_integrity_http.py new file mode 100644 index 000000000..88891b6ce --- /dev/null +++ b/services/people-api/tests/test_employment_separation_integrity_http.py @@ -0,0 +1,140 @@ +"""HTTP regression for internal Employment separation receipt corruption.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from datetime import datetime, timezone +import json +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.postgres_separation import PostgresEmploymentSeparationPort +from orgmetra_people_api.separation_http import EmploymentSeparationAsgiApp + +TENANT = UUID("0198a413-1000-7000-8000-000000000001") +PERSON = UUID("0198a413-1000-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a413-1000-7000-8000-000000000030") +EXPECTED_VERSION = UUID("0198a413-1000-7000-8000-000000000031") +AUDIT_EVENT = UUID("0198a413-1000-7000-8000-000000000080") +OUTBOX = UUID("0198a413-1000-7000-8000-000000000081") + + +class Authenticator: + """Return the exact principal used by the HTTP command.""" + + async def authenticate(self, bearer_token: str) -> AuthenticatedPrincipal: + if bearer_token != "opaque-token": + raise AssertionError("unexpected bearer token") + return AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:people-operator-18", + granted_scope_codes=frozenset({"orgmetra.people.write"}), + ) + + +class MalformedReceiptCursor(AbstractContextManager["MalformedReceiptCursor"]): + """Return a database receipt whose recorded time violates the typed boundary.""" + + def __enter__(self) -> "MalformedReceiptCursor": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + return None + + def execute(self, sql: str, parameters: object | None = None) -> None: + del sql, parameters + + def fetchmany(self, size: int) -> list[tuple[object, object, object, object]]: + if size != 2: + raise AssertionError("adapter must bound result reads") + return [(EMPLOYMENT, EXPECTED_VERSION, "not-a-database-timestamp", False)] + + +class Connection(AbstractContextManager["Connection"]): + """Expose the malformed receipt through the normal transaction context.""" + + def __enter__(self) -> "Connection": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + return None + + def cursor(self) -> MalformedReceiptCursor: + return MalformedReceiptCursor() + + +class EmploymentSeparationIntegrityHttpTests(unittest.IsolatedAsyncioTestCase): + """Keep database-integrity faults distinct from client-resolvable conflicts.""" + + async def test_malformed_database_receipt_is_internal_error_not_client_conflict(self) -> None: + policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="employment-separation-v1", + resource_kind="employment_record", + purpose_code="workforce_admin", + operation_code="separate_record", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({"employment_record"}), + ) + generated = iter((AUDIT_EVENT, OUTBOX)) + app = EmploymentSeparationAsgiApp( + authenticator=Authenticator(), + policy=policy, + separation_port=PostgresEmploymentSeparationPort(Connection), + id_factory=generated.__next__, + ) + body = json.dumps( + { + "person_record_id": str(PERSON), + "employment_record_id": str(EMPLOYMENT), + "expected_employment_record_version_id": str(EXPECTED_VERSION), + "separation_effective_on": "2026-10-01", + "separation_reason_code": "voluntary_resignation", + "evidence_reference": "separation_packet:integrity-case", + "evidence_version_code": "v1", + "confirmation_reference": "human_confirmation:integrity-case", + }, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + scope = { + "type": "http", + "method": "POST", + "path": "/v1/employment-separations", + "query_string": b"", + "headers": [ + (b"authorization", b"Bearer opaque-token"), + (b"content-type", b"application/json"), + (b"idempotency-key", b"employment-separation-integrity-case"), + (b"x-tenant-reference", str(TENANT).encode("ascii")), + (b"x-actor-reference", b"keyverse_subject:people-operator-18"), + (b"x-purpose-code", b"workforce_admin"), + ], + } + messages: list[dict[str, object]] = [] + received = False + + async def receive() -> dict[str, object]: + nonlocal received + if received: + return {"type": "http.disconnect"} + received = True + return {"type": "http.request", "body": body, "more_body": False} + + async def send(message: dict[str, object]) -> None: + messages.append(message) + + await app(scope, receive, send) + + start, response = messages + payload = json.loads(bytes(response["body"])) + self.assertEqual(start["status"], 500) + self.assertEqual(payload["error_code"], "internal_error") + self.assertIn("support_reference", payload) + self.assertNotIn("not-a-database-timestamp", json.dumps(payload)) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 362329dfd9994f7f6c7dfe9ab2d6f4359b6b7908 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:04:41 +0900 Subject: [PATCH 191/269] fix(people): distinguish separation persistence integrity faults --- services/people-api/src/orgmetra_people_api/separation.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/separation.py b/services/people-api/src/orgmetra_people_api/separation.py index 8e20d045a..919f3fc2c 100644 --- a/services/people-api/src/orgmetra_people_api/separation.py +++ b/services/people-api/src/orgmetra_people_api/separation.py @@ -23,7 +23,11 @@ class EmploymentSeparationIntegrityError(RuntimeError): - """Indicate that separation evidence cannot be trusted as the requested result.""" + """Indicate that authoritative Employment state conflicts with the requested transition.""" + + +class EmploymentSeparationPersistenceIntegrityError(EmploymentSeparationIntegrityError): + """Indicate that trusted persistence evidence or authorization wiring is internally invalid.""" def _operational_uuid(field_name: str, value: object) -> UUID: @@ -191,5 +195,5 @@ def separate_employment_record( raise TypeError("separation_port must return EmploymentSeparationResult") detached_result = replace(result) if detached_result.employment_record_id != expected_employment_record_id: - raise EmploymentSeparationIntegrityError("separation result identity does not match command") + raise EmploymentSeparationPersistenceIntegrityError("separation result identity does not match command") return detached_result From 1ae78e12e5226c7edaf27dec19e418e6e4ff2379 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:04:58 +0900 Subject: [PATCH 192/269] fix(people): type separation persistence integrity failures --- .../postgres_separation.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_separation.py b/services/people-api/src/orgmetra_people_api/postgres_separation.py index e5c381a81..f94e3509b 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_separation.py +++ b/services/people-api/src/orgmetra_people_api/postgres_separation.py @@ -12,6 +12,7 @@ from orgmetra_people_api.separation import ( EmploymentSeparationCommand, EmploymentSeparationIntegrityError, + EmploymentSeparationPersistenceIntegrityError, EmploymentSeparationResult, ) @@ -39,7 +40,9 @@ def _require_authorization( ) -> AuthorizationDecision: """Require exact allow evidence for the governed separation operation.""" if type(authorization) is not AuthorizationDecision: - raise EmploymentSeparationIntegrityError("Employment separation requires typed authorization evidence") + raise EmploymentSeparationPersistenceIntegrityError( + "Employment separation requires typed authorization evidence" + ) if ( not authorization.allowed or authorization.tenant_record_id != command.tenant_record_id @@ -50,7 +53,9 @@ def _require_authorization( or authorization.requested_fields != _EMPLOYMENT_FIELDS or authorization.authorized_fields != _EMPLOYMENT_FIELDS ): - raise EmploymentSeparationIntegrityError("Employment separation authorization does not match the exact record") + raise EmploymentSeparationPersistenceIntegrityError( + "Employment separation authorization does not match the exact record" + ) return authorization @@ -58,10 +63,14 @@ def _one_result_row(cursor: Any) -> tuple[object, object, object, object]: """Detach exactly one built-in database row before validating result evidence.""" rows = cursor.fetchmany(2) if type(rows) not in (list, tuple) or len(rows) != 1: - raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") + raise EmploymentSeparationPersistenceIntegrityError( + "Employment separation database result is invalid" + ) row = rows[0] if type(row) not in (list, tuple) or len(row) != 4: - raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") + raise EmploymentSeparationPersistenceIntegrityError( + "Employment separation database result is invalid" + ) return row[0], row[1], row[2], row[3] @@ -79,9 +88,13 @@ def _validated_result( replayed=row[3], # type: ignore[arg-type] ) except (TypeError, ValueError) as error: - raise EmploymentSeparationIntegrityError("Employment separation database result is invalid") from error + raise EmploymentSeparationPersistenceIntegrityError( + "Employment separation database result is invalid" + ) from error if result.employment_record_id != expected_employment_record_id: - raise EmploymentSeparationIntegrityError("Employment separation database identity does not match command") + raise EmploymentSeparationPersistenceIntegrityError( + "Employment separation database identity does not match command" + ) return result From 05b400d28981e91297fdf49cee7b6a5c68768ed6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:05:28 +0900 Subject: [PATCH 193/269] fix(people): keep persistence integrity faults server-side --- .../orgmetra_people_api/separation_http.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/separation_http.py b/services/people-api/src/orgmetra_people_api/separation_http.py index dccf975e2..ef6b70b65 100644 --- a/services/people-api/src/orgmetra_people_api/separation_http.py +++ b/services/people-api/src/orgmetra_people_api/separation_http.py @@ -32,6 +32,7 @@ from orgmetra_people_api.separation import ( EmploymentSeparationCommand, EmploymentSeparationIntegrityError, + EmploymentSeparationPersistenceIntegrityError, EmploymentSeparationPort, separate_employment_record, ) @@ -309,6 +310,28 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send payload={"error": "record_not_found", "message": "Verify the Person, Employment, and expected version, then retry."}, ) return + except EmploymentSeparationPersistenceIntegrityError as error: + support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + _LOGGER.error( + "Employment separation persistence integrity failed", + extra={ + "tenant_record_id": str(headers.tenant_record_id), + "employment_record_id": str(command.employment_record_id), + "correlation_reference": f"audit_event_record:{command.audit_event_record_id.hex}", + "exception_type": type(error).__name__, + "support_reference": support_reference, + }, + ) + await _send_error( + send, + status=500, + payload={ + "error": "internal_error", + "message": "Retry later or contact an Orgmetra operator with the support reference.", + }, + support_reference=support_reference, + ) + return except EmploymentSeparationIntegrityError: await _send_error( send, From f6db78de19054bfdaf7f5dbf6b70508d0f179b0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:11:21 +0900 Subject: [PATCH 194/269] test(people): preserve separation error taxonomy contracts --- .../tests/test_postgres_employment_separation.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/services/people-api/tests/test_postgres_employment_separation.py b/services/people-api/tests/test_postgres_employment_separation.py index 89ead3dd3..8f0ea66f3 100644 --- a/services/people-api/tests/test_postgres_employment_separation.py +++ b/services/people-api/tests/test_postgres_employment_separation.py @@ -13,6 +13,7 @@ from orgmetra_people_api.separation import ( EmploymentSeparationCommand, EmploymentSeparationIntegrityError, + EmploymentSeparationPersistenceIntegrityError, ) TENANT = UUID("0198a412-8000-7000-8000-000000000001") @@ -189,7 +190,7 @@ def test_rejects_authorization_that_does_not_match_exact_operation(self) -> None authorization(authorized_fields=frozenset({"assignment_record"})), ) for decision in cases: - with self.subTest(decision=decision), self.assertRaises(EmploymentSeparationIntegrityError): + with self.subTest(decision=decision), self.assertRaises(EmploymentSeparationPersistenceIntegrityError): port.separate_employment(command=command(), authorization=decision) # type: ignore[arg-type] def test_rejects_malformed_database_result_before_transaction_commit_boundary(self) -> None: @@ -205,16 +206,16 @@ def test_rejects_malformed_database_result_before_transaction_commit_boundary(se ) for row in rows: connection = FakeConnection(FakeCursor(row=row)) - with self.subTest(row=row), self.assertRaises(EmploymentSeparationIntegrityError): + with self.subTest(row=row), self.assertRaises(EmploymentSeparationPersistenceIntegrityError): port = PostgresEmploymentSeparationPort(ConnectionFactory(connection)) port.separate_employment(command=command(), authorization=authorization()) - self.assertIs(connection.exit_exception_type, EmploymentSeparationIntegrityError) + self.assertIs(connection.exit_exception_type, EmploymentSeparationPersistenceIntegrityError) connection = FakeConnection(InvalidBatchCursor()) - with self.assertRaises(EmploymentSeparationIntegrityError): + with self.assertRaises(EmploymentSeparationPersistenceIntegrityError): port = PostgresEmploymentSeparationPort(ConnectionFactory(connection)) port.separate_employment(command=command(), authorization=authorization()) - self.assertIs(connection.exit_exception_type, EmploymentSeparationIntegrityError) + self.assertIs(connection.exit_exception_type, EmploymentSeparationPersistenceIntegrityError) def test_maps_governed_conflicts_but_not_permission_failures(self) -> None: cases = ( From 6f89e6c26a1bf6c66d29c1125d6b7f525a67b4a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:13:12 +0900 Subject: [PATCH 195/269] docs(people): trace separation integrity error taxonomy --- docs/traceability/employment-separation.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index 63478dbbf..2f1193d8a 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -16,11 +16,13 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Retry safety | People mutation idempotency contract | exact tenant+route+idempotency-key advisory lock; same semantic command replays first terminal version/timestamp; changed command under same key fails | implemented_on_active_pr | | Purpose-bound application authorization | Keyverse adapter + ADR 0015 | `separation.py` authorizes the exact Employment, tenant, `workforce_admin` purpose, `separate_record` operation and `orgmetra.people.write` scope before the persistence port; command and result identities are detached and rebound | implemented_on_active_pr | | Governed persistence adapter | ADR 0015 | `PostgresEmploymentSeparationPort` starts one READ COMMITTED read/write transaction, binds tenant context, invokes only `separate_employment_record_once(...)`, validates one typed DB result before the connection context may commit, maps reviewed domain SQLSTATEs, and leaves permission failures operational | implemented_on_active_pr | -| Commit-boundary result integrity | #314 | `test_postgres_employment_separation.py` requires malformed/mismatched DB evidence to leave the connection context with `EmploymentSeparationIntegrityError`, so a driver/result-integrity failure rolls back rather than becoming a committed write followed by an application-only failure | implemented_on_active_pr | +| Commit-boundary result integrity | #314 | `test_postgres_employment_separation.py` requires malformed/mismatched DB evidence to leave the connection context with `EmploymentSeparationPersistenceIntegrityError`, so a driver/result-integrity failure rolls back rather than becoming a committed write followed by an application-only failure | implemented_on_active_pr | +| Buyer-visible error taxonomy | #314 | `test_employment_separation_integrity_http.py` drives a malformed PostgreSQL receipt through the real adapter and HTTP edge; persistence/authorization/result-integrity faults return sanitized `500 internal_error` + support reference, while authoritative state conflicts retain `409 separation_conflict` | implemented_on_active_pr | | Deny-default SQL capability | ADR 0015 | migrations `0015`/`0016` revoke PUBLIC, create separate `NOLOGIN`/`NOBYPASSRLS` owner and executor roles, move the function to the owner as SECURITY DEFINER, revoke temporary schema CREATE, and grant the executor function EXECUTE only | implemented_on_active_pr | | No direct DML bypass from runtime capability | ADR 0015 | `tests/test_employment_separation_capability_postgres.sh` requires the executor to have no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE rights on governed People/audit/outbox relations while still crossing the function boundary | awaiting_foundation_registration | | Capability-test failure isolation | #314 | the unrelated probe role uses collision-resistant per-execution identity; failure cleanup is best-effort without masking the causal error, while nominal success requires strict verified role cleanup | implemented_on_active_pr | -| Real concurrent-first serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on an advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | +| Same-key concurrent replay serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on the idempotency advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | +| Distinct-key Employment conflict serialization | #314 acceptance #5 | current SQL locks the Employment anchor after the per-key idempotency boundary, but the focused contract still needs a two-session scenario with different idempotency keys against one expected Employment version, an observed Employment-scoped blocker, one committed separation, and one stale/conflict loser | acceptance_gap | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | | Canonical CI ownership | #311 | both focused PostgreSQL contracts must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | | Buyer HTTP boundary | #314 | `EmploymentSeparationAsgiApp` authenticates and binds tenant/actor before the body is accepted, requires `workforce_admin`, preserves expected-version/date/reason/evidence/confirmation/idempotency semantics, generates server-owned audit/outbox identities, returns replay plus DB-owned recorded time, and sanitizes backend failures | implemented_on_active_pr | @@ -32,10 +34,12 @@ This trace binds issue #314 to the first authoritative Employment separation sli `database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015, the application/persistence ports, the buyer-facing ASGI route, canonical OpenAPI contract, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. -`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision, issues no direct People or audit/outbox DML, and validates returned persistence evidence before transaction-context exit so rejected evidence can still force rollback. `separation_http.py` is the buyer request edge: it binds authentication, tenant, actor, purpose, exact command fields and client-safe error semantics before delegating the synchronous persistence operation off the ASGI event loop. Server-generated audit/outbox identity failure is an operational failure and is not misclassified as invalid buyer input. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. +`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision, issues no direct People or audit/outbox DML, and validates returned persistence evidence before transaction-context exit so rejected evidence can still force rollback. Trusted persistence/authorization/result-integrity faults use `EmploymentSeparationPersistenceIntegrityError`; reviewed authoritative-state conflicts retain `EmploymentSeparationIntegrityError`. `separation_http.py` maps the former to sanitized operator-actionable 500 responses and the latter to client-resolvable 409 responses, so buyers are not told to refresh or change an idempotency key when the service itself returned corrupted evidence. Server-generated audit/outbox identity failure is likewise operational and is not misclassified as invalid buyer input. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. The existing Foundation workflow remains owned by its canonical Foundation stack. The new PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. +The existing concurrency acceptance proves same-key idempotency serialization only. It does not yet satisfy #314 acceptance #5 for two different idempotency keys targeting the same Employment/version. That remaining test must observe the Employment aggregate conflict boundary directly, prove that exactly one logical separation commits, and prove the loser fails on stale/authoritative state without duplicate audit/outbox/idempotency truth. Do not weaken this into timing-only sleeps or treat the same-key advisory lock as equivalent evidence. + ## Next owner handoff Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL contracts rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. @@ -44,4 +48,4 @@ The People runtime owner must bind its application/database login to `orgmetra_e The remaining buyer-visible route gap is realistic buyer-path p95/E2E evidence under the deployed executor capability. No performance claim is made from unit tests, static schema checks, or queued CI alone. -Once those owner boundaries are reconciled and exact-head hosted evidence is green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. +Once those owner boundaries are reconciled, the distinct-key Employment concurrency acceptance is executable, and exact-head hosted evidence is green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From ba766db1a6a1f596b7cc287ebb265c35e5d21105 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:14:29 +0900 Subject: [PATCH 196/269] test(people): remove unused separation integrity imports --- .../tests/test_employment_separation_integrity_http.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services/people-api/tests/test_employment_separation_integrity_http.py b/services/people-api/tests/test_employment_separation_integrity_http.py index 88891b6ce..d535195b0 100644 --- a/services/people-api/tests/test_employment_separation_integrity_http.py +++ b/services/people-api/tests/test_employment_separation_integrity_http.py @@ -3,7 +3,6 @@ from __future__ import annotations from contextlib import AbstractContextManager -from datetime import datetime, timezone import json import unittest from uuid import UUID From a67ed4b1afec755356d1cb4149007bfabaf10090 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:29:29 +0900 Subject: [PATCH 197/269] test(people): prove distinct-key separation serialization --- ...ent_separation_distinct_key_concurrency.sh | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 tests/test_employment_separation_distinct_key_concurrency.sh diff --git a/tests/test_employment_separation_distinct_key_concurrency.sh b/tests/test_employment_separation_distinct_key_concurrency.sh new file mode 100644 index 000000000..785e09d42 --- /dev/null +++ b/tests/test_employment_separation_distinct_key_concurrency.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" +TENANT_ID='10000000-0000-7000-8000-000000000001' +PERSON_ID='00000000-0000-7000-8000-000000000001' +EMPLOYMENT_ID='00000000-0000-7000-8000-000000000105' +EXPECTED_VERSION_ID='00000000-0000-7000-8000-000000000206' + +# This is a companion to test_employment_separation_postgres.sh. The root contract +# owns schema migration and tenant/person setup; this scenario adds only an isolated +# Employment fixture so it can prove aggregate serialization without reapplying schema. +tenant_psql() { + PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" command psql "$@" +} + +tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&- + first_fd_open=false + fi + if [[ -n "${first_pid}" ]] && kill -0 "${first_pid}" 2>/dev/null; then + kill "${first_pid}" 2>/dev/null || true + wait "${first_pid}" 2>/dev/null || true + fi + if [[ -n "${second_pid}" ]] && kill -0 "${second_pid}" 2>/dev/null; then + kill "${second_pid}" 2>/dev/null || true + wait "${second_pid}" 2>/dev/null || true + fi + rm -rf "${runtime_dir}" +} +trap cleanup EXIT + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_employment_separation_distinct_first' \ +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <"${first_fifo}" >"${first_output}" 2>&1 & +first_pid=$! +exec 3>"${first_fifo}" +first_fd_open=true +cat >&3 <<'SQL' +BEGIN; +SELECT employment_record_id, separated_employment_record_version_id, recorded_at, replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000105'::uuid, + '00000000-0000-7000-8000-000000000206'::uuid, + DATE '2026-08-15', + 'voluntary_resignation', + 'separation_packet:sep-distinct-key', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-distinct-key', + 'employment-separation-distinct-key-a', + '00000000-0000-4000-8000-000000000510'::uuid, + '00000000-0000-4000-8000-000000000610'::uuid +); +\echo ORGMETRA_DISTINCT_FIRST_READY +SQL + +first_ready=false +for _ in $(seq 1 100); do + if grep -q '^ORGMETRA_DISTINCT_FIRST_READY$' "${first_output}" 2>/dev/null; then + first_ready=true + break + fi + if ! kill -0 "${first_pid}" 2>/dev/null; then + break + fi + sleep 0.05 +done +if [[ "${first_ready}" != "true" ]]; then + cat "${first_output}" >&2 || true + echo "first distinct-key separation transaction did not reach the controlled pre-commit boundary" >&2 + exit 1 +fi + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_employment_separation_distinct_second' \ +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' >"${second_output}" 2>&1 <<'SQL' & +SET statement_timeout = '10s'; +SELECT employment_record_id, separated_employment_record_version_id, recorded_at, replayed +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000105'::uuid, + '00000000-0000-7000-8000-000000000206'::uuid, + DATE '2026-08-15', + 'voluntary_resignation', + 'separation_packet:sep-distinct-key', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-distinct-key', + 'employment-separation-distinct-key-b', + '00000000-0000-4000-8000-000000000511'::uuid, + '00000000-0000-4000-8000-000000000611'::uuid +); +SQL +second_pid=$! + +aggregate_blocker_observed=false +observed_wait_event='' +for _ in $(seq 1 100); do + lock_row="$(psql "${DATABASE_URL}" -AtqF '|' -c " + SELECT second_session.wait_event, + count(*) + FROM pg_catalog.pg_stat_activity AS second_session + JOIN pg_catalog.pg_stat_activity AS first_session + ON first_session.application_name = 'orgmetra_employment_separation_distinct_first' + WHERE second_session.application_name = 'orgmetra_employment_separation_distinct_second' + AND second_session.wait_event_type = 'Lock' + AND second_session.wait_event IN ('transactionid', 'tuple') + AND first_session.pid = ANY(pg_catalog.pg_blocking_pids(second_session.pid)) + GROUP BY second_session.wait_event; + ")" + if [[ -n "${lock_row}" ]]; then + observed_wait_event="${lock_row%%|*}" + if [[ "${lock_row#*|}" == "1" ]]; then + aggregate_blocker_observed=true + break + fi + fi + if ! kill -0 "${second_pid}" 2>/dev/null; then + break + fi + sleep 0.05 +done + +# Release the first transaction only after the second backend has either exposed +# its database blocker or failed. The polling interval is coordination, not proof; +# pg_blocking_pids plus a row/transaction lock wait is the acceptance evidence. +printf 'COMMIT;\n\\q\n' >&3 +exec 3>&- +first_fd_open=false + +set +e +wait "${first_pid}" +first_status=$? +wait "${second_pid}" +second_status=$? +set -e +first_pid='' +second_pid='' + +if [[ "${aggregate_blocker_observed}" != "true" ]]; then + cat "${second_output}" >&2 || true + echo "different idempotency keys never exposed the first Employment transaction as a row-level blocker" >&2 + exit 1 +fi +if [[ "${observed_wait_event}" == "advisory" || -z "${observed_wait_event}" ]]; then + echo "distinct-key concurrency was incorrectly qualified through the idempotency advisory lock: ${observed_wait_event}" >&2 + exit 1 +fi +if [[ ${first_status} -ne 0 ]]; then + cat "${first_output}" >&2 || true + echo "first distinct-key separation failed unexpectedly: ${first_status}" >&2 + exit 1 +fi +if [[ ${second_status} -eq 0 ]]; then + cat "${second_output}" >&2 || true + echo "second distinct-key separation unexpectedly committed against the stale expected Employment version" >&2 + exit 1 +fi +if ! grep -q 'expected version is stale or unavailable' "${second_output}"; then + cat "${second_output}" >&2 || true + echo "distinct-key loser did not fail as an authoritative stale-version conflict" >&2 + exit 1 +fi + +first_row="$(grep '^00000000-0000-7000-8000-000000000105|' "${first_output}" | head -n 1)" +if [[ -z "${first_row}" || "$(printf '%s' "${first_row}" | cut -d'|' -f4)" != "f" ]]; then + cat "${first_output}" >&2 || true + echo "distinct-key winner did not return one first-write result" >&2 + exit 1 +fi + +truth_counts="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <> 'type' = 'orgmetra.people.employment_separated' + AND canonical_event_json::jsonb ->> 'subject' = 'employment_record:${EMPLOYMENT_ID}'), + (SELECT count(*) + FROM public.outbox_delivery_record AS outbox + JOIN public.audit_event_record AS audit + ON audit.tenant_record_id = outbox.tenant_record_id + AND audit.audit_event_record_id = outbox.audit_event_record_id + WHERE audit.tenant_record_id = '${TENANT_ID}'::uuid + AND audit.canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated' + AND audit.canonical_event_json::jsonb ->> 'subject' = 'employment_record:${EMPLOYMENT_ID}'), + (SELECT count(*) + FROM public.people_mutation_idempotency_record AS idem + JOIN public.employment_separation_record AS separation + ON separation.tenant_record_id = idem.tenant_record_id + AND separation.separated_employment_record_version_id = idem.created_record_id + WHERE idem.tenant_record_id = '${TENANT_ID}'::uuid + AND idem.command_route = 'employment-separations' + AND separation.employment_record_id = '${EMPLOYMENT_ID}'::uuid), + (SELECT count(*) + FROM public.people_mutation_idempotency_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND command_route = 'employment-separations' + AND idempotency_key = 'employment-separation-distinct-key-b') +); +SQL +)" +if [[ "${truth_counts}" != "1|1|1|1|0" ]]; then + echo "distinct-key concurrency produced duplicate or loser-side durable truth: ${truth_counts}" >&2 + exit 1 +fi + +echo "PostgreSQL Employment separation distinct-key concurrency contract passed" From fbcaee1f0c8e8012e6013b27568400bb631e6785 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:30:23 +0900 Subject: [PATCH 198/269] docs(people): trace distinct-key separation acceptance --- docs/traceability/employment-separation.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index 2f1193d8a..1a4b44990 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -22,9 +22,9 @@ This trace binds issue #314 to the first authoritative Employment separation sli | No direct DML bypass from runtime capability | ADR 0015 | `tests/test_employment_separation_capability_postgres.sh` requires the executor to have no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE rights on governed People/audit/outbox relations while still crossing the function boundary | awaiting_foundation_registration | | Capability-test failure isolation | #314 | the unrelated probe role uses collision-resistant per-execution identity; failure cleanup is best-effort without masking the causal error, while nominal success requires strict verified role cleanup | implemented_on_active_pr | | Same-key concurrent replay serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on the idempotency advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | -| Distinct-key Employment conflict serialization | #314 acceptance #5 | current SQL locks the Employment anchor after the per-key idempotency boundary, but the focused contract still needs a two-session scenario with different idempotency keys against one expected Employment version, an observed Employment-scoped blocker, one committed separation, and one stale/conflict loser | acceptance_gap | +| Distinct-key Employment conflict serialization | #314 acceptance #5 | `tests/test_employment_separation_distinct_key_concurrency.sh` holds the first successful mutation at a shell-controlled pre-commit FIFO boundary, races a different idempotency key against the same Employment/version, requires `pg_blocking_pids(...)` plus a row/transaction lock wait rather than an advisory wait, then requires one committed separation and a stale-version loser with no loser-side separation/audit/outbox/idempotency truth | implemented_on_active_pr; awaiting_foundation_registration | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | -| Canonical CI ownership | #311 | both focused PostgreSQL contracts must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | +| Canonical CI ownership | #311 | the separation root and capability root plus the distinct-key companion must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | | Buyer HTTP boundary | #314 | `EmploymentSeparationAsgiApp` authenticates and binds tenant/actor before the body is accepted, requires `workforce_admin`, preserves expected-version/date/reason/evidence/confirmation/idempotency semantics, generates server-owned audit/outbox identities, returns replay plus DB-owned recorded time, and sanitizes backend failures | implemented_on_active_pr | | Canonical OpenAPI route contract | #314 | `schemas/openapi.yaml` publishes `POST /v1/employment-separations`; focused route/schema regression verifies the exact request/result/error surface | implemented_on_active_pr | | Buyer-path performance journey | #314 | the published route still needs realistic async E2E/k6 measurement against the operational persistence capability before any p95 claim is made | planned | @@ -38,14 +38,14 @@ This trace binds issue #314 to the first authoritative Employment separation sli The existing Foundation workflow remains owned by its canonical Foundation stack. The new PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. -The existing concurrency acceptance proves same-key idempotency serialization only. It does not yet satisfy #314 acceptance #5 for two different idempotency keys targeting the same Employment/version. That remaining test must observe the Employment aggregate conflict boundary directly, prove that exactly one logical separation commits, and prove the loser fails on stale/authoritative state without duplicate audit/outbox/idempotency truth. Do not weaken this into timing-only sleeps or treat the same-key advisory lock as equivalent evidence. +Distinct-key Employment concurrency now has a focused executable companion rather than a timing-only acceptance note. The first backend reaches a successful mutation and is kept inside its transaction by a shell-controlled FIFO, not by a database sleep. Only then is a second backend started with a different idempotency key but the same Employment and expected version. Acceptance requires the second backend to expose the first through `pg_blocking_pids(...)` while waiting on a row/transaction lock, explicitly not the per-key advisory lock. Releasing the first transaction must yield one committed separation and a stale-version loser; separation, audit event, outbox delivery and idempotency truth are counted afterward and the losing key must have no durable idempotency row. This artifact is not called hosted GREEN until #311 registers it as a companion and executes it from the immutable exact-candidate tree. ## Next owner handoff -Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL contracts rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. +Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL roots and bind `tests/test_employment_separation_distinct_key_concurrency.sh` as a reviewed companion of the separation root, rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. The remaining buyer-visible route gap is realistic buyer-path p95/E2E evidence under the deployed executor capability. No performance claim is made from unit tests, static schema checks, or queued CI alone. -Once those owner boundaries are reconciled, the distinct-key Employment concurrency acceptance is executable, and exact-head hosted evidence is green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. +Once those owner boundaries are reconciled, the distinct-key Employment concurrency companion has exact-head hosted PostgreSQL evidence, and the remaining security/review gates are green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From b3a30d8651ae66e4514298db5be623d653277e21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 03:32:18 +0900 Subject: [PATCH 199/269] test(people): observe distinct-key pre-commit boundary in PostgreSQL --- ...mployment_separation_distinct_key_concurrency.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_employment_separation_distinct_key_concurrency.sh b/tests/test_employment_separation_distinct_key_concurrency.sh index 785e09d42..a70df00c0 100644 --- a/tests/test_employment_separation_distinct_key_concurrency.sh +++ b/tests/test_employment_separation_distinct_key_concurrency.sh @@ -98,12 +98,19 @@ FROM public.separate_employment_record_once( '00000000-0000-4000-8000-000000000510'::uuid, '00000000-0000-4000-8000-000000000610'::uuid ); -\echo ORGMETRA_DISTINCT_FIRST_READY SQL first_ready=false for _ in $(seq 1 100); do - if grep -q '^ORGMETRA_DISTINCT_FIRST_READY$' "${first_output}" 2>/dev/null; then + first_state="$(psql "${DATABASE_URL}" -Atqc " + SELECT count(*) + FROM pg_catalog.pg_stat_activity + WHERE application_name = 'orgmetra_employment_separation_distinct_first' + AND state = 'idle in transaction' + AND wait_event_type = 'Client' + AND wait_event = 'ClientRead'; + ")" + if [[ "${first_state}" == "1" ]]; then first_ready=true break fi @@ -114,7 +121,7 @@ for _ in $(seq 1 100); do done if [[ "${first_ready}" != "true" ]]; then cat "${first_output}" >&2 || true - echo "first distinct-key separation transaction did not reach the controlled pre-commit boundary" >&2 + echo "first distinct-key separation transaction did not reach the observable pre-commit ClientRead boundary" >&2 exit 1 fi From 92e4c36b011142f6d81baa1f0caec925484b4acd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 04:02:15 +0900 Subject: [PATCH 200/269] test(people): prove separation failure cleanup --- ...t_employment_separation_failure_cleanup.sh | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 tests/test_employment_separation_failure_cleanup.sh diff --git a/tests/test_employment_separation_failure_cleanup.sh b/tests/test_employment_separation_failure_cleanup.sh new file mode 100644 index 000000000..9e8d768ee --- /dev/null +++ b/tests/test_employment_separation_failure_cleanup.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" +TENANT_ID='10000000-0000-7000-8000-000000000001' +PERSON_ID='00000000-0000-7000-8000-000000000001' +EMPLOYMENT_ID='00000000-0000-7000-8000-000000000106' +EXPECTED_VERSION_ID='00000000-0000-7000-8000-000000000207' +FIRST_APP='orgmetra_employment_separation_cleanup_first' +SECOND_APP='orgmetra_employment_separation_cleanup_second' +FIRST_KEY='employment-separation-cleanup-key-a' +SECOND_KEY='employment-separation-cleanup-key-b' + +tenant_psql() { + PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" command psql "$@" +} + +prerequisite="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT CASE + WHEN pg_catalog.to_regprocedure( + 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)' + ) IS NOT NULL THEN 1 + ELSE 0 +END; +")" +if [[ "${prerequisite}" != "1" ]]; then + echo "Employment separation failure-cleanup companion requires the root contract to run first" >&2 + exit 1 +fi + +tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 < pg_catalog.pg_backend_pid(); + " >/dev/null 2>&1 || true +} + +cleanup() { + local saved_status=$? + set +e + if [[ "${first_fd_open}" == "true" ]]; then + exec 3>&- + first_fd_open=false + fi + if [[ -n "${second_pid}" ]] && kill -0 "${second_pid}" 2>/dev/null; then + kill "${second_pid}" 2>/dev/null || true + wait "${second_pid}" 2>/dev/null || true + fi + if [[ -n "${first_pid}" ]] && kill -0 "${first_pid}" 2>/dev/null; then + kill "${first_pid}" 2>/dev/null || true + wait "${first_pid}" 2>/dev/null || true + fi + terminate_named_backends_best_effort + rm -rf "${runtime_dir}" + return "${saved_status}" +} +trap cleanup EXIT + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME="${FIRST_APP}" \ +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <"${first_fifo}" >"${first_output}" 2>&1 & +first_pid=$! +exec 3>"${first_fifo}" +first_fd_open=true +cat >&3 </dev/null; then + break + fi + sleep 0.05 +done +if [[ "${first_ready}" != "true" ]]; then + cat "${first_output}" >&2 || true + echo "first failure-cleanup transaction did not reach the observable pre-commit ClientRead boundary" >&2 + exit 1 +fi + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME="${SECOND_APP}" \ +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' >"${second_output}" 2>&1 </dev/null; then + break + fi + sleep 0.05 +done +if [[ "${blocker_observed}" != "true" ]]; then + cat "${second_output}" >&2 || true + echo "failure-cleanup scenario never established an observable Employment blocker" >&2 + exit 1 +fi + +first_backend_pid="$(psql "${DATABASE_URL}" -Atqc " + SELECT pid + FROM pg_catalog.pg_stat_activity + WHERE application_name = '${FIRST_APP}'; +")" +second_backend_pid="$(psql "${DATABASE_URL}" -Atqc " + SELECT pid + FROM pg_catalog.pg_stat_activity + WHERE application_name = '${SECOND_APP}'; +")" +if [[ ! "${first_backend_pid}" =~ ^[0-9]+$ || ! "${second_backend_pid}" =~ ^[0-9]+$ ]]; then + echo "failure-cleanup scenario could not bind exact PostgreSQL backend identities" >&2 + exit 1 +fi + +# Terminate the blocked loser first so releasing the winner cannot let the loser +# commit before its disconnected client is noticed. Then terminate the winner. +second_terminated="$(psql "${DATABASE_URL}" -Atqc "SELECT pg_catalog.pg_terminate_backend(${second_backend_pid});")" +if [[ "${second_terminated}" != "t" ]]; then + echo "failed to terminate the blocked failure-cleanup backend" >&2 + exit 1 +fi + +second_server_gone=false +for _ in $(seq 1 100); do + second_count="$(psql "${DATABASE_URL}" -Atqc " + SELECT count(*) + FROM pg_catalog.pg_stat_activity + WHERE application_name = '${SECOND_APP}'; + ")" + if [[ "${second_count}" == "0" ]]; then + second_server_gone=true + break + fi + sleep 0.05 +done +if [[ "${second_server_gone}" != "true" ]]; then + echo "blocked failure-cleanup backend did not quiesce after termination" >&2 + exit 1 +fi + +first_terminated="$(psql "${DATABASE_URL}" -Atqc "SELECT pg_catalog.pg_terminate_backend(${first_backend_pid});")" +if [[ "${first_terminated}" != "t" ]]; then + echo "failed to terminate the pre-commit failure-cleanup backend" >&2 + exit 1 +fi + +exec 3>&- +first_fd_open=false + +set +e +wait "${second_pid}" +second_status=$? +wait "${first_pid}" +first_status=$? +set -e +second_pid='' +first_pid='' +if [[ ${first_status} -eq 0 || ${second_status} -eq 0 ]]; then + echo "failure-cleanup client unexpectedly reported success: first=${first_status} second=${second_status}" >&2 + exit 1 +fi + +server_quiesced=false +for _ in $(seq 1 100); do + server_count="$(psql "${DATABASE_URL}" -Atqc " + SELECT count(*) + FROM pg_catalog.pg_stat_activity + WHERE application_name IN ('${FIRST_APP}', '${SECOND_APP}'); + ")" + if [[ "${server_count}" == "0" ]]; then + server_quiesced=true + break + fi + sleep 0.05 +done +if [[ "${server_quiesced}" != "true" ]]; then + psql "${DATABASE_URL}" -x -c " + SELECT pid, application_name, state, wait_event_type, wait_event + FROM pg_catalog.pg_stat_activity + WHERE application_name IN ('${FIRST_APP}', '${SECOND_APP}'); + " >&2 || true + echo "Employment separation failure cleanup left a PostgreSQL backend alive" >&2 + exit 1 +fi + +truth_after_failure="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <> 'type' = 'orgmetra.people.employment_separated' + AND canonical_event_json::jsonb ->> 'subject' = 'employment_record:${EMPLOYMENT_ID}'), + (SELECT count(*) + FROM public.outbox_delivery_record AS outbox + JOIN public.audit_event_record AS audit + ON audit.tenant_record_id = outbox.tenant_record_id + AND audit.audit_event_record_id = outbox.audit_event_record_id + WHERE audit.tenant_record_id = '${TENANT_ID}'::uuid + AND audit.canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.employment_separated' + AND audit.canonical_event_json::jsonb ->> 'subject' = 'employment_record:${EMPLOYMENT_ID}'), + (SELECT count(*) + FROM public.people_mutation_idempotency_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND command_route = 'employment-separations' + AND idempotency_key IN ('${FIRST_KEY}', '${SECOND_KEY}')), + (SELECT count(*) + FROM public.employment_record_version + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '${EMPLOYMENT_ID}'::uuid + AND employment_record_version_id = '${EXPECTED_VERSION_ID}'::uuid + AND employment_status_code = 'active' + AND recorded_to IS NULL) +); +SQL +)" +if [[ "${truth_after_failure}" != "0|0|0|0|1" ]]; then + echo "failure cleanup left partial separation truth or failed to restore the original current Employment version: ${truth_after_failure}" >&2 + exit 1 +fi + +echo "PostgreSQL Employment separation failure-cleanup contract passed" From ea9d53488a05d5c4ded038b2cbe8db2e6ea3962c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 04:03:14 +0900 Subject: [PATCH 201/269] test(people): qualify separation cleanup by durable rollback --- tests/test_employment_separation_failure_cleanup.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_employment_separation_failure_cleanup.sh b/tests/test_employment_separation_failure_cleanup.sh index 9e8d768ee..5f6b19d5f 100644 --- a/tests/test_employment_separation_failure_cleanup.sh +++ b/tests/test_employment_separation_failure_cleanup.sh @@ -262,8 +262,8 @@ first_status=$? set -e second_pid='' first_pid='' -if [[ ${first_status} -eq 0 || ${second_status} -eq 0 ]]; then - echo "failure-cleanup client unexpectedly reported success: first=${first_status} second=${second_status}" >&2 +if [[ ${second_status} -eq 0 ]]; then + echo "blocked failure-cleanup client unexpectedly reported success; first=${first_status} second=${second_status}" >&2 exit 1 fi From 52db8ff03d1c7ec8963b9d0a24c7f1e9d7579077 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 04:03:45 +0900 Subject: [PATCH 202/269] docs(people): trace separation failure cleanup --- docs/traceability/employment-separation.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index 1a4b44990..52e9f701f 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -23,8 +23,9 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Capability-test failure isolation | #314 | the unrelated probe role uses collision-resistant per-execution identity; failure cleanup is best-effort without masking the causal error, while nominal success requires strict verified role cleanup | implemented_on_active_pr | | Same-key concurrent replay serialization | #314 | `tests/test_employment_separation_postgres.sh` requires the second backend to expose the first backend through `pg_blocking_pids(...)` while waiting on the idempotency advisory lock, then converge on one first result plus one replay | awaiting_foundation_registration | | Distinct-key Employment conflict serialization | #314 acceptance #5 | `tests/test_employment_separation_distinct_key_concurrency.sh` holds the first successful mutation at a shell-controlled pre-commit FIFO boundary, races a different idempotency key against the same Employment/version, requires `pg_blocking_pids(...)` plus a row/transaction lock wait rather than an advisory wait, then requires one committed separation and a stale-version loser with no loser-side separation/audit/outbox/idempotency truth | implemented_on_active_pr; awaiting_foundation_registration | +| Failure cleanup and transaction rollback | #314 acceptance #8 | `tests/test_employment_separation_failure_cleanup.sh` establishes an observable distinct-key blocker, terminates the blocked loser before the pre-commit winner, waits both client processes, requires both named PostgreSQL backends to disappear from `pg_stat_activity`, and then proves zero separation/audit/outbox/idempotency residue while the original active Employment version remains current | implemented_on_active_pr; awaiting_foundation_registration | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | -| Canonical CI ownership | #311 | the separation root and capability root plus the distinct-key companion must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | +| Canonical CI ownership | #311 | the separation root and capability root plus the distinct-key and failure-cleanup companions must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | | Buyer HTTP boundary | #314 | `EmploymentSeparationAsgiApp` authenticates and binds tenant/actor before the body is accepted, requires `workforce_admin`, preserves expected-version/date/reason/evidence/confirmation/idempotency semantics, generates server-owned audit/outbox identities, returns replay plus DB-owned recorded time, and sanitizes backend failures | implemented_on_active_pr | | Canonical OpenAPI route contract | #314 | `schemas/openapi.yaml` publishes `POST /v1/employment-separations`; focused route/schema regression verifies the exact request/result/error surface | implemented_on_active_pr | | Buyer-path performance journey | #314 | the published route still needs realistic async E2E/k6 measurement against the operational persistence capability before any p95 claim is made | planned | @@ -40,12 +41,14 @@ The existing Foundation workflow remains owned by its canonical Foundation stack Distinct-key Employment concurrency now has a focused executable companion rather than a timing-only acceptance note. The first backend reaches a successful mutation and is kept inside its transaction by a shell-controlled FIFO, not by a database sleep. Only then is a second backend started with a different idempotency key but the same Employment and expected version. Acceptance requires the second backend to expose the first through `pg_blocking_pids(...)` while waiting on a row/transaction lock, explicitly not the per-key advisory lock. Releasing the first transaction must yield one committed separation and a stale-version loser; separation, audit event, outbox delivery and idempotency truth are counted afterward and the losing key must have no durable idempotency row. This artifact is not called hosted GREEN until #311 registers it as a companion and executes it from the immutable exact-candidate tree. +Failure cleanup is now a separate executable acceptance rather than an assumption inherited from normal-path `wait` calls. The cleanup companion first establishes the same real Employment blocker, then terminates the blocked loser while the winning transaction is still held pre-commit, waits for that server session to disappear, terminates the winner, closes the FIFO, waits both client processes, and requires the named server-session count to reach zero before reading durable state. The elapsed polling interval is coordination only; `pg_stat_activity` quiescence plus zero separation/audit/outbox/idempotency residue and restoration of the original current Employment version are the acceptance evidence. This companion is likewise pending canonical #311 registration and exact-tree execution. + ## Next owner handoff -Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL roots and bind `tests/test_employment_separation_distinct_key_concurrency.sh` as a reviewed companion of the separation root, rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. +Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL roots and bind both `tests/test_employment_separation_distinct_key_concurrency.sh` and `tests/test_employment_separation_failure_cleanup.sh` as reviewed companions of the separation root, rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. The remaining buyer-visible route gap is realistic buyer-path p95/E2E evidence under the deployed executor capability. No performance claim is made from unit tests, static schema checks, or queued CI alone. -Once those owner boundaries are reconciled, the distinct-key Employment concurrency companion has exact-head hosted PostgreSQL evidence, and the remaining security/review gates are green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. +Once those owner boundaries are reconciled, both separation companions have exact-head hosted PostgreSQL evidence, and the remaining security/review gates are green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From 07cf1143906125dec527bd4272a5ff9c5746baf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 05:05:54 +0900 Subject: [PATCH 203/269] test(people): cover separation uncertain commit recovery --- ...nt_separation_uncertain_commit_recovery.sh | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 tests/test_employment_separation_uncertain_commit_recovery.sh diff --git a/tests/test_employment_separation_uncertain_commit_recovery.sh b/tests/test_employment_separation_uncertain_commit_recovery.sh new file mode 100644 index 000000000..0365b04b6 --- /dev/null +++ b/tests/test_employment_separation_uncertain_commit_recovery.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" +TENANT_ID='10000000-0000-7000-8000-000000000001' +PERSON_ID='00000000-0000-7000-8000-000000000001' +EMPLOYMENT_ID='00000000-0000-7000-8000-000000000107' +EXPECTED_VERSION_ID='00000000-0000-7000-8000-000000000208' +FIRST_APP='orgmetra_employment_separation_uncertain_commit' +IDEMPOTENCY_KEY='employment-separation-uncertain-commit-key' +FIRST_AUDIT_ID='00000000-0000-4000-8000-000000000530' +FIRST_OUTBOX_ID='00000000-0000-4000-8000-000000000630' +RETRY_AUDIT_ID='00000000-0000-4000-8000-000000000531' +RETRY_OUTBOX_ID='00000000-0000-4000-8000-000000000631' + +tenant_psql() { + PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" command psql "$@" +} + +prerequisite="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT CASE + WHEN pg_catalog.to_regprocedure( + 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)' + ) IS NOT NULL THEN 1 + ELSE 0 +END; +")" +if [[ "${prerequisite}" != "1" ]]; then + echo "Employment separation uncertain-commit companion requires the root contract to run first" >&2 + exit 1 +fi + +tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 < pg_catalog.pg_backend_pid(); + " >/dev/null 2>&1 || true +} + +cleanup() { + local saved_status=$? + set +e + if [[ -n "${first_pid}" ]] && kill -0 "${first_pid}" 2>/dev/null; then + kill "${first_pid}" 2>/dev/null || true + wait "${first_pid}" 2>/dev/null || true + fi + terminate_named_backend_best_effort + rm -rf "${runtime_dir}" + return "${saved_status}" +} +trap cleanup EXIT + +# The first client commits the separation, then remains in pg_sleep. The observer +# below qualifies commit through durable database state, not through first-client +# output. Terminating the still-running backend makes the caller observe failure +# after the authoritative transaction has already committed. +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME="${FIRST_APP}" \ +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' >"${first_output}" 2>&1 </dev/null; then + break + fi + sleep 0.05 +done +if [[ "${committed}" != "true" || ! "${first_backend_pid}" =~ ^[0-9]+$ ]]; then + cat "${first_output}" >&2 || true + echo "uncertain-commit scenario never exposed one durable committed separation while the first client remained connected" >&2 + exit 1 +fi + +first_wait="$(psql "${DATABASE_URL}" -AtqF '|' -c " + SELECT state, wait_event_type, wait_event + FROM pg_catalog.pg_stat_activity + WHERE pid = ${first_backend_pid} + AND application_name = '${FIRST_APP}'; +")" +if [[ "${first_wait}" != "active|Timeout|PgSleep" ]]; then + echo "first uncertain-commit backend was not held after commit in pg_sleep: ${first_wait}" >&2 + exit 1 +fi + +terminated="$(psql "${DATABASE_URL}" -Atqc "SELECT pg_catalog.pg_terminate_backend(${first_backend_pid});")" +if [[ "${terminated}" != "t" ]]; then + echo "failed to terminate committed uncertain-outcome backend" >&2 + exit 1 +fi + +set +e +wait "${first_pid}" +first_status=$? +set -e +first_pid='' +if [[ ${first_status} -eq 0 ]]; then + cat "${first_output}" >&2 || true + echo "first uncertain-commit client unexpectedly reported success after backend termination" >&2 + exit 1 +fi + +server_quiesced=false +for _ in $(seq 1 100); do + server_count="$(psql "${DATABASE_URL}" -Atqc " + SELECT count(*) + FROM pg_catalog.pg_stat_activity + WHERE application_name = '${FIRST_APP}'; + ")" + if [[ "${server_count}" == "0" ]]; then + server_quiesced=true + break + fi + sleep 0.05 +done +if [[ "${server_quiesced}" != "true" ]]; then + echo "uncertain-commit backend did not quiesce after termination" >&2 + exit 1 +fi + +retry_result="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <&2 + exit 1 +fi + +final_truth="$(tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -AtqF '|' <&2 + exit 1 +fi + +echo "PostgreSQL Employment separation uncertain-commit recovery contract passed" From c099e367f80843910face46c45d4c02b1ee0ce51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 05:06:42 +0900 Subject: [PATCH 204/269] docs(people): trace uncertain commit recovery --- docs/traceability/employment-separation.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index 52e9f701f..c927fa3f5 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -14,6 +14,7 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Database-owned recorded time | #314 | one post-lock `clock_timestamp()` closes prior recorded history, opens replacement versions, stamps separation provenance and is serialized into the audit event `time` | implemented_on_active_pr | | High-impact governance evidence | ADR 0006 + ADR 0008 + ADR 0015 | controlled reason, evidence reference/version, actor, `workforce_admin` purpose and human confirmation are mandatory; audit/outbox is persisted in the same transaction | implemented_on_active_pr | | Retry safety | People mutation idempotency contract | exact tenant+route+idempotency-key advisory lock; same semantic command replays first terminal version/timestamp; changed command under same key fails | implemented_on_active_pr | +| Uncertain-commit recovery | People mutation idempotency contract + #314 | `tests/test_employment_separation_uncertain_commit_recovery.sh` proves a committed separation remains recoverable when the original caller later fails: an independent observer qualifies durable commit, terminates the still-running first backend, then a fresh connection with the same semantic command/key must replay the exact first terminal version and DB-owned timestamp without creating retry audit/outbox truth | implemented_on_active_pr; awaiting_foundation_registration | | Purpose-bound application authorization | Keyverse adapter + ADR 0015 | `separation.py` authorizes the exact Employment, tenant, `workforce_admin` purpose, `separate_record` operation and `orgmetra.people.write` scope before the persistence port; command and result identities are detached and rebound | implemented_on_active_pr | | Governed persistence adapter | ADR 0015 | `PostgresEmploymentSeparationPort` starts one READ COMMITTED read/write transaction, binds tenant context, invokes only `separate_employment_record_once(...)`, validates one typed DB result before the connection context may commit, maps reviewed domain SQLSTATEs, and leaves permission failures operational | implemented_on_active_pr | | Commit-boundary result integrity | #314 | `test_postgres_employment_separation.py` requires malformed/mismatched DB evidence to leave the connection context with `EmploymentSeparationPersistenceIntegrityError`, so a driver/result-integrity failure rolls back rather than becoming a committed write followed by an application-only failure | implemented_on_active_pr | @@ -25,7 +26,7 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Distinct-key Employment conflict serialization | #314 acceptance #5 | `tests/test_employment_separation_distinct_key_concurrency.sh` holds the first successful mutation at a shell-controlled pre-commit FIFO boundary, races a different idempotency key against the same Employment/version, requires `pg_blocking_pids(...)` plus a row/transaction lock wait rather than an advisory wait, then requires one committed separation and a stale-version loser with no loser-side separation/audit/outbox/idempotency truth | implemented_on_active_pr; awaiting_foundation_registration | | Failure cleanup and transaction rollback | #314 acceptance #8 | `tests/test_employment_separation_failure_cleanup.sh` establishes an observable distinct-key blocker, terminates the blocked loser before the pre-commit winner, waits both client processes, requires both named PostgreSQL backends to disappear from `pg_stat_activity`, and then proves zero separation/audit/outbox/idempotency residue while the original active Employment version remains current | implemented_on_active_pr; awaiting_foundation_registration | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | -| Canonical CI ownership | #311 | the separation root and capability root plus the distinct-key and failure-cleanup companions must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | +| Canonical CI ownership | #311 | the separation root and capability root plus the distinct-key, failure-cleanup and uncertain-commit companions must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | | Buyer HTTP boundary | #314 | `EmploymentSeparationAsgiApp` authenticates and binds tenant/actor before the body is accepted, requires `workforce_admin`, preserves expected-version/date/reason/evidence/confirmation/idempotency semantics, generates server-owned audit/outbox identities, returns replay plus DB-owned recorded time, and sanitizes backend failures | implemented_on_active_pr | | Canonical OpenAPI route contract | #314 | `schemas/openapi.yaml` publishes `POST /v1/employment-separations`; focused route/schema regression verifies the exact request/result/error surface | implemented_on_active_pr | | Buyer-path performance journey | #314 | the published route still needs realistic async E2E/k6 measurement against the operational persistence capability before any p95 claim is made | planned | @@ -43,12 +44,14 @@ Distinct-key Employment concurrency now has a focused executable companion rathe Failure cleanup is now a separate executable acceptance rather than an assumption inherited from normal-path `wait` calls. The cleanup companion first establishes the same real Employment blocker, then terminates the blocked loser while the winning transaction is still held pre-commit, waits for that server session to disappear, terminates the winner, closes the FIFO, waits both client processes, and requires the named server-session count to reach zero before reading durable state. The elapsed polling interval is coordination only; `pg_stat_activity` quiescence plus zero separation/audit/outbox/idempotency residue and restoration of the original current Employment version are the acceptance evidence. This companion is likewise pending canonical #311 registration and exact-tree execution. +Uncertain commit is qualified separately from ordinary same-key replay. `tests/test_employment_separation_uncertain_commit_recovery.sh` creates an isolated Employment, performs the separation and COMMIT on the first connection, then deliberately keeps that backend alive in `pg_sleep`. A second connection must first observe the separation/idempotency/audit/outbox rows as durable; only that external database evidence qualifies the commit. The first backend is then terminated so the original client process reports failure despite the already-committed transaction. A fresh connection retries the same semantic command and idempotency key with new server-generated audit/outbox identities. Acceptance requires replay of the exact first terminal version and `recorded_at`, exactly one separation/idempotency/audit/outbox truth set, and zero rows for the retry-only audit/outbox identities. The first client's output is not recovery evidence. This companion is pending canonical #311 registration and exact-tree PostgreSQL execution. + ## Next owner handoff -Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL roots and bind both `tests/test_employment_separation_distinct_key_concurrency.sh` and `tests/test_employment_separation_failure_cleanup.sh` as reviewed companions of the separation root, rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. +Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL roots and bind `tests/test_employment_separation_distinct_key_concurrency.sh`, `tests/test_employment_separation_failure_cleanup.sh`, and `tests/test_employment_separation_uncertain_commit_recovery.sh` as reviewed companions of the separation root, rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. The remaining buyer-visible route gap is realistic buyer-path p95/E2E evidence under the deployed executor capability. No performance claim is made from unit tests, static schema checks, or queued CI alone. -Once those owner boundaries are reconciled, both separation companions have exact-head hosted PostgreSQL evidence, and the remaining security/review gates are green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. +Once those owner boundaries are reconciled, all separation companions have exact-head hosted PostgreSQL evidence, and the remaining security/review gates are green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From 7fdde5cb21194950c0828de067dde670f33f940e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:04:42 +0900 Subject: [PATCH 205/269] test(people): expose assignment separation lock race --- ...nment_separation_anchor_lock_regression.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 services/people-api/tests/test_assignment_separation_anchor_lock_regression.py diff --git a/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py b/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py new file mode 100644 index 000000000..d8c837cc4 --- /dev/null +++ b/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py @@ -0,0 +1,48 @@ +"""Regression contract for Assignment versus Employment-separation serialization.""" + +from __future__ import annotations + +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from test_people_mutations import assignment_command +from test_postgres_people_mutations import ( + CONVERSION, + RECORDED_AT, + FakeConnection, + ScriptedCursor, + assignment_authorization, + covering_employment_row, + covering_position_row, +) + + +def test_assignment_locks_employment_anchor_before_reading_versions() -> None: + """Acquire the separation-shared anchor lock in its own READ COMMITTED statement.""" + cursor = ScriptedCursor( + [[], [(CONVERSION, RECORDED_AT)]], + [[covering_employment_row()], [covering_position_row()], []], + ) + connection = FakeConnection(cursor) + port = PostgresPeopleMutationPort(lambda: connection) + + port.create_assignment( + command=assignment_command(), + authorization=assignment_authorization(), + ) + + statements = [sql for sql, _parameters in cursor.executions] + lock_indexes = [ + index + for index, sql in enumerate(statements) + if "FROM public.employment_record AS employment" in sql + and "FOR UPDATE OF employment" in sql + and "employment_record_version" not in sql + ] + version_index = next( + index + for index, sql in enumerate(statements) + if "JOIN public.employment_record_version AS version" in sql + and "employment.employment_record_id = %s" in sql + ) + + assert len(lock_indexes) == 1 + assert lock_indexes[0] < version_index From acd99acd1bba5e8f789c052551a5195e43dcf71c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:06:00 +0900 Subject: [PATCH 206/269] fix(people): serialize assignment against employment separation --- ...nt_employment_separation_serialization.sql | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 database/migrations/0017_assignment_employment_separation_serialization.sql diff --git a/database/migrations/0017_assignment_employment_separation_serialization.sql b/database/migrations/0017_assignment_employment_separation_serialization.sql new file mode 100644 index 000000000..a582d29ce --- /dev/null +++ b/database/migrations/0017_assignment_employment_separation_serialization.sql @@ -0,0 +1,65 @@ +-- Serialize Assignment creation against Employment separation on the Employment aggregate. +-- +-- Separation already locks employment_record before it inspects Assignment truth. Assignment +-- creation must acquire the same anchor lock before INSERT and then re-read current Employment +-- versions under READ COMMITTED. This closes the check/insert race in both directions without +-- holding an external workflow or broad table lock inside the transaction. + +BEGIN; + +SET LOCAL search_path = public, pg_catalog; + +CREATE FUNCTION public.guard_assignment_employment_coverage() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +DECLARE + v_anchor_person_id uuid; + v_has_covering_employment boolean; +BEGIN + SELECT employment.person_record_id + INTO v_anchor_person_id + FROM public.employment_record AS employment + WHERE employment.tenant_record_id = NEW.tenant_record_id + AND employment.employment_record_id = NEW.employment_record_id + FOR UPDATE OF employment; + + IF NOT FOUND OR v_anchor_person_id IS DISTINCT FROM NEW.person_record_id THEN + RAISE EXCEPTION 'assignment target does not match tenant person and employment' + USING ERRCODE = '23503'; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM public.employment_record_version AS version + WHERE version.tenant_record_id = NEW.tenant_record_id + AND version.employment_record_id = NEW.employment_record_id + AND version.recorded_to IS NULL + AND version.employment_status_code IN ('active', 'leave') + AND version.effective_from <= NEW.effective_from + AND ( + version.effective_to IS NULL + OR ( + NEW.effective_to IS NOT NULL + AND NEW.effective_to <= version.effective_to + ) + ) + ) + INTO v_has_covering_employment; + + IF v_has_covering_employment IS NOT TRUE THEN + RAISE EXCEPTION 'assignment requires current active or leave Employment coverage' + USING ERRCODE = '55000'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER assignment_employment_coverage_guard +BEFORE INSERT ON public.assignment_record +FOR EACH ROW +EXECUTE FUNCTION public.guard_assignment_employment_coverage(); + +COMMIT; From 0bd405a6699d6a99b41227cd7900ca711c8f6c92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:06:38 +0900 Subject: [PATCH 207/269] test(people): bind assignment separation guard to database owner --- ...nment_separation_anchor_lock_regression.py | 52 +++++-------------- 1 file changed, 13 insertions(+), 39 deletions(-) diff --git a/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py b/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py index d8c837cc4..a1a4c3b07 100644 --- a/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py +++ b/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py @@ -2,47 +2,21 @@ from __future__ import annotations -from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort -from test_people_mutations import assignment_command -from test_postgres_people_mutations import ( - CONVERSION, - RECORDED_AT, - FakeConnection, - ScriptedCursor, - assignment_authorization, - covering_employment_row, - covering_position_row, -) +from pathlib import Path -def test_assignment_locks_employment_anchor_before_reading_versions() -> None: - """Acquire the separation-shared anchor lock in its own READ COMMITTED statement.""" - cursor = ScriptedCursor( - [[], [(CONVERSION, RECORDED_AT)]], - [[covering_employment_row()], [covering_position_row()], []], - ) - connection = FakeConnection(cursor) - port = PostgresPeopleMutationPort(lambda: connection) +_REPO_ROOT = Path(__file__).resolve().parents[3] +_MIGRATION = _REPO_ROOT / "database/migrations/0017_assignment_employment_separation_serialization.sql" - port.create_assignment( - command=assignment_command(), - authorization=assignment_authorization(), - ) - statements = [sql for sql, _parameters in cursor.executions] - lock_indexes = [ - index - for index, sql in enumerate(statements) - if "FROM public.employment_record AS employment" in sql - and "FOR UPDATE OF employment" in sql - and "employment_record_version" not in sql - ] - version_index = next( - index - for index, sql in enumerate(statements) - if "JOIN public.employment_record_version AS version" in sql - and "employment.employment_record_id = %s" in sql - ) +def test_assignment_insert_uses_employment_anchor_as_shared_conflict_boundary() -> None: + """Keep the Assignment/Employment race guard at the authoritative PostgreSQL boundary.""" + sql = _MIGRATION.read_text(encoding="utf-8") - assert len(lock_indexes) == 1 - assert lock_indexes[0] < version_index + assert "CREATE FUNCTION public.guard_assignment_employment_coverage()" in sql + assert "FOR UPDATE OF employment" in sql + assert "CREATE TRIGGER assignment_employment_coverage_guard" in sql + assert "BEFORE INSERT ON public.assignment_record" in sql + assert "version.recorded_to IS NULL" in sql + assert "version.employment_status_code IN ('active', 'leave')" in sql + assert "NEW.effective_to <= version.effective_to" in sql From f3d6bf890b6d08cf5b25ad50b91533b83fbd5a41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:08:12 +0900 Subject: [PATCH 208/269] test(people): prove assignment separation serialization --- ...nment_separation_serialization_postgres.sh | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 tests/test_assignment_separation_serialization_postgres.sh diff --git a/tests/test_assignment_separation_serialization_postgres.sh b/tests/test_assignment_separation_serialization_postgres.sh new file mode 100644 index 000000000..bb490e56f --- /dev/null +++ b/tests/test_assignment_separation_serialization_postgres.sh @@ -0,0 +1,380 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" +TENANT_ID='10000000-0000-7000-8000-000000000001' +PERSON_ID='10000000-0000-7000-8000-000000000101' +EMPLOYMENT_ASSIGNMENT_FIRST='10000000-0000-7000-8000-000000000111' +VERSION_ASSIGNMENT_FIRST='10000000-0000-7000-8000-000000000211' +EMPLOYMENT_SEPARATION_FIRST='10000000-0000-7000-8000-000000000112' +VERSION_SEPARATION_FIRST='10000000-0000-7000-8000-000000000212' +ORGANIZATION_ID='10000000-0000-7000-8000-000000000121' +JOB_ID='10000000-0000-7000-8000-000000000131' +POSITION_ID='10000000-0000-7000-8000-000000000141' +ASSIGNMENT_FIRST_ID='10000000-0000-7000-8000-000000000151' +ASSIGNMENT_LOSER_ID='10000000-0000-7000-8000-000000000152' +HISTORICAL_ASSIGNMENT_ID='10000000-0000-7000-8000-000000000153' + +# This root deliberately stops at the separation domain migration before applying +# the new serialization guard. Capability-owner migrations 0015/0016 use +# cluster-global roles and are verified by their own isolated acceptance lane. +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0003_audit_outbox_persistence.sql \ + database/migrations/0004_outbox_delivery_claim.sql \ + database/migrations/0005_outbox_delivery_finalization.sql \ + database/migrations/0006_outbox_delivery_dead_letter.sql \ + database/migrations/0007_outbox_retry_exhaustion.sql \ + database/migrations/0008_audit_outbox_review_hardening.sql \ + database/migrations/0009_candidate_worker_conversion_governance.sql \ + database/migrations/0010_validity_study_case_integrity.sql \ + database/migrations/0011_criterion_observation_scope.sql \ + database/migrations/0012_people_mutation_idempotency.sql \ + database/migrations/0013_job_analysis_snapshot.sql \ + database/migrations/0014_employment_separation_transition.sql \ + database/migrations/0017_assignment_employment_separation_serialization.sql; do + psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -f "${migration}" >/dev/null +done + +tenant_psql() { + PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" command psql "$@" +} + +psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 </dev/null +INSERT INTO public.tenant_record (tenant_record_id, tenant_reference) +VALUES ('${TENANT_ID}'::uuid, 'assignment_separation_serialization'); + +INSERT INTO public.person_record (tenant_record_id, person_record_id, recorded_from) +VALUES ('${TENANT_ID}'::uuid, '${PERSON_ID}'::uuid, TIMESTAMPTZ '2026-01-02 00:00:00+00'); + +INSERT INTO public.employment_record ( + tenant_record_id, employment_record_id, person_record_id, recorded_from +) VALUES + ('${TENANT_ID}'::uuid, '${EMPLOYMENT_ASSIGNMENT_FIRST}'::uuid, '${PERSON_ID}'::uuid, TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('${TENANT_ID}'::uuid, '${EMPLOYMENT_SEPARATION_FIRST}'::uuid, '${PERSON_ID}'::uuid, TIMESTAMPTZ '2026-01-02 00:00:00+00'); + +INSERT INTO public.employment_record_version ( + tenant_record_id, + employment_record_version_id, + employment_record_id, + employment_status_code, + employment_concurrency_code, + effective_from, + effective_to, + recorded_from +) VALUES + ('${TENANT_ID}'::uuid, '${VERSION_ASSIGNMENT_FIRST}'::uuid, '${EMPLOYMENT_ASSIGNMENT_FIRST}'::uuid, + 'active', 'concurrent', DATE '2026-01-01', NULL, TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('${TENANT_ID}'::uuid, '${VERSION_SEPARATION_FIRST}'::uuid, '${EMPLOYMENT_SEPARATION_FIRST}'::uuid, + 'active', 'concurrent', DATE '2026-01-01', NULL, TIMESTAMPTZ '2026-01-02 00:00:00+00'); + +INSERT INTO public.organization_unit (tenant_record_id, organization_unit_id, recorded_from) +VALUES ('${TENANT_ID}'::uuid, '${ORGANIZATION_ID}'::uuid, TIMESTAMPTZ '2026-01-02 00:00:00+00'); + +INSERT INTO public.job_profile (tenant_record_id, job_profile_id, recorded_from) +VALUES ('${TENANT_ID}'::uuid, '${JOB_ID}'::uuid, TIMESTAMPTZ '2026-01-02 00:00:00+00'); + +INSERT INTO public.position_record ( + tenant_record_id, position_record_id, organization_unit_id, job_profile_id, recorded_from +) VALUES ( + '${TENANT_ID}'::uuid, + '${POSITION_ID}'::uuid, + '${ORGANIZATION_ID}'::uuid, + '${JOB_ID}'::uuid, + TIMESTAMPTZ '2026-01-02 00:00:00+00' +); +SQL + +runtime_dir="$(mktemp -d)" +assignment_fifo="${runtime_dir}/assignment-first.sql" +separation_fifo="${runtime_dir}/separation-first.sql" +assignment_output="${runtime_dir}/assignment-first.out" +separation_after_assignment_output="${runtime_dir}/separation-after-assignment.out" +separation_output="${runtime_dir}/separation-first.out" +assignment_after_separation_output="${runtime_dir}/assignment-after-separation.out" +mkfifo "${assignment_fifo}" "${separation_fifo}" +assignment_pid='' +separation_after_assignment_pid='' +separation_pid='' +assignment_after_separation_pid='' +assignment_fd_open=false +separation_fd_open=false + +cleanup() { + set +e + if [[ "${assignment_fd_open}" == "true" ]]; then exec 3>&-; fi + if [[ "${separation_fd_open}" == "true" ]]; then exec 4>&-; fi + for pid in "${assignment_pid}" "${separation_after_assignment_pid}" "${separation_pid}" "${assignment_after_separation_pid}"; do + if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then + kill "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + fi + done + psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=0 -Atqc " + SELECT pg_catalog.pg_terminate_backend(pid) + FROM pg_catalog.pg_stat_activity + WHERE application_name LIKE 'orgmetra_assignment_separation_%' + AND pid <> pg_catalog.pg_backend_pid(); + " >/dev/null 2>&1 || true + rm -rf "${runtime_dir}" +} +trap cleanup EXIT + +wait_for_client_read() { + local app_name=$1 + local client_pid=$2 + for _ in $(seq 1 120); do + state="$(psql "${DATABASE_URL}" -X -Atqc " + SELECT count(*) + FROM pg_catalog.pg_stat_activity + WHERE application_name = '${app_name}' + AND state = 'idle in transaction' + AND wait_event_type = 'Client' + AND wait_event = 'ClientRead'; + ")" + if [[ "${state}" == "1" ]]; then + return 0 + fi + if ! kill -0 "${client_pid}" 2>/dev/null; then + return 1 + fi + sleep 0.05 + done + return 1 +} + +wait_for_blocker() { + local blocked_app=$1 + local blocker_app=$2 + for _ in $(seq 1 120); do + lock_row="$(psql "${DATABASE_URL}" -X -AtqF '|' -c " + SELECT blocked.wait_event, count(*) + FROM pg_catalog.pg_stat_activity AS blocked + JOIN pg_catalog.pg_stat_activity AS blocker + ON blocker.application_name = '${blocker_app}' + WHERE blocked.application_name = '${blocked_app}' + AND blocked.wait_event_type = 'Lock' + AND blocked.wait_event IN ('transactionid', 'tuple') + AND blocker.pid = ANY(pg_catalog.pg_blocking_pids(blocked.pid)) + GROUP BY blocked.wait_event; + ")" + if [[ -n "${lock_row}" && "${lock_row#*|}" == "1" ]]; then + return 0 + fi + sleep 0.05 + done + return 1 +} + +# Scenario A: Assignment obtains the Employment anchor first. Separation must wait +# on that exact row-level conflict and, after the Assignment commits, fail closed +# because the new Assignment extends through/after the requested separation date. +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_assignment_separation_assignment_first' \ +psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -Atq <"${assignment_fifo}" >"${assignment_output}" 2>&1 & +assignment_pid=$! +exec 3>"${assignment_fifo}" +assignment_fd_open=true +cat >&3 <&2 || true + echo "assignment-first transaction did not reach the observable pre-commit ClientRead boundary" >&2 + exit 1 +fi + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_assignment_separation_separation_after_assignment' \ +psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -Atq >"${separation_after_assignment_output}" 2>&1 <&2 || true + echo "separation never observed the uncommitted Assignment as the Employment-anchor blocker" >&2 + exit 1 +fi + +printf 'COMMIT;\n\\q\n' >&3 +exec 3>&- +assignment_fd_open=false +set +e +wait "${assignment_pid}" +assignment_status=$? +wait "${separation_after_assignment_pid}" +separation_after_assignment_status=$? +set -e +assignment_pid='' +separation_after_assignment_pid='' + +if [[ ${assignment_status} -ne 0 ]]; then + cat "${assignment_output}" >&2 || true + echo "assignment-first writer failed unexpectedly" >&2 + exit 1 +fi +if [[ ${separation_after_assignment_status} -eq 0 ]] || ! grep -q 'assignment coordination before termination' "${separation_after_assignment_output}"; then + cat "${separation_after_assignment_output}" >&2 || true + echo "separation did not fail closed after the competing Assignment committed" >&2 + exit 1 +fi + +scenario_a_truth="$(tenant_psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -AtqF '|' -c " +SELECT concat_ws('|', + (SELECT count(*) FROM public.assignment_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND assignment_record_id = '${ASSIGNMENT_FIRST_ID}'::uuid), + (SELECT count(*) FROM public.employment_separation_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '${EMPLOYMENT_ASSIGNMENT_FIRST}'::uuid) +);")" +if [[ "${scenario_a_truth}" != "1|0" ]]; then + echo "assignment-first durable truth is inconsistent: ${scenario_a_truth}" >&2 + exit 1 +fi + +# Scenario B: Separation obtains the same Employment anchor first. Assignment must +# block, then re-evaluate coverage after the separation commit and fail rather than +# insert a fact extending into the terminal Employment interval. +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_assignment_separation_separation_first' \ +psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -AtqF '|' <"${separation_fifo}" >"${separation_output}" 2>&1 & +separation_pid=$! +exec 4>"${separation_fifo}" +separation_fd_open=true +cat >&4 <&2 || true + echo "separation-first transaction did not reach the observable pre-commit ClientRead boundary" >&2 + exit 1 +fi + +PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ +PGAPPNAME='orgmetra_assignment_separation_assignment_after_separation' \ +psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -Atq >"${assignment_after_separation_output}" 2>&1 <&2 || true + echo "Assignment never observed the uncommitted separation as the Employment-anchor blocker" >&2 + exit 1 +fi + +printf 'COMMIT;\n\\q\n' >&4 +exec 4>&- +separation_fd_open=false +set +e +wait "${separation_pid}" +separation_status=$? +wait "${assignment_after_separation_pid}" +assignment_after_separation_status=$? +set -e +separation_pid='' +assignment_after_separation_pid='' + +if [[ ${separation_status} -ne 0 ]]; then + cat "${separation_output}" >&2 || true + echo "separation-first writer failed unexpectedly" >&2 + exit 1 +fi +if [[ ${assignment_after_separation_status} -eq 0 ]] || ! grep -q 'assignment requires current active or leave Employment coverage' "${assignment_after_separation_output}"; then + cat "${assignment_after_separation_output}" >&2 || true + echo "Assignment did not fail closed after the competing separation committed" >&2 + exit 1 +fi + +scenario_b_truth="$(tenant_psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -AtqF '|' -c " +SELECT concat_ws('|', + (SELECT count(*) FROM public.employment_separation_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND employment_record_id = '${EMPLOYMENT_SEPARATION_FIRST}'::uuid), + (SELECT count(*) FROM public.assignment_record + WHERE tenant_record_id = '${TENANT_ID}'::uuid + AND assignment_record_id = '${ASSIGNMENT_LOSER_ID}'::uuid) +);")" +if [[ "${scenario_b_truth}" != "1|0" ]]; then + echo "separation-first durable truth is inconsistent: ${scenario_b_truth}" >&2 + exit 1 +fi + +# Historical Assignment truth ending exactly at separation remains legal. The guard +# must reject only intervals not fully covered by a current active/leave version. +tenant_psql "${DATABASE_URL}" -X -v ON_ERROR_STOP=1 -Atq <&2 + exit 1 +fi + +echo "PostgreSQL Assignment/Employment-separation serialization contract passed" From 012005d9910bebc517fb06b025b8aaa966ab3230 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:09:45 +0900 Subject: [PATCH 209/269] fix(people): least-privilege assignment separation guard --- ...nt_employment_separation_serialization.sql | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/database/migrations/0017_assignment_employment_separation_serialization.sql b/database/migrations/0017_assignment_employment_separation_serialization.sql index a582d29ce..7fbfa564e 100644 --- a/database/migrations/0017_assignment_employment_separation_serialization.sql +++ b/database/migrations/0017_assignment_employment_separation_serialization.sql @@ -4,10 +4,50 @@ -- creation must acquire the same anchor lock before INSERT and then re-read current Employment -- versions under READ COMMITTED. This closes the check/insert race in both directions without -- holding an external workflow or broad table lock inside the transaction. +-- +-- PostgreSQL requires UPDATE privilege for SELECT ... FOR UPDATE. Do not widen the ordinary +-- Assignment writer merely to obtain the conflict lock: a dedicated NOLOGIN/NOBYPASSRLS owner +-- executes the trigger with only the reviewed read/anchor-lock capabilities, while FORCE RLS +-- continues to bind every lookup to the caller's transaction-local tenant context. + +DO $orgmetra_assignment_employment_guard_role_preflight$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles + WHERE rolname = 'orgmetra_assignment_employment_guard_owner' + ) THEN + RAISE EXCEPTION 'pre-existing Assignment Employment guard capability role is not accepted' + USING ERRCODE = '42710'; + END IF; +END; +$orgmetra_assignment_employment_guard_role_preflight$; BEGIN; -SET LOCAL search_path = public, pg_catalog; +SET LOCAL search_path = pg_catalog, public; + +CREATE ROLE orgmetra_assignment_employment_guard_owner + NOLOGIN + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + +GRANT USAGE ON SCHEMA public TO orgmetra_assignment_employment_guard_owner; +GRANT SELECT ON TABLE + public.employment_record, + public.employment_record_version +TO orgmetra_assignment_employment_guard_owner; + +-- SELECT ... FOR UPDATE requires an UPDATE privilege. Grant only one inert anchor +-- column; the trigger never mutates employment_record and the role is not a login. +GRANT UPDATE (recorded_from) ON TABLE public.employment_record + TO orgmetra_assignment_employment_guard_owner; +GRANT EXECUTE ON FUNCTION public.current_tenant_record_id() + TO orgmetra_assignment_employment_guard_owner; CREATE FUNCTION public.guard_assignment_employment_coverage() RETURNS trigger @@ -57,9 +97,20 @@ BEGIN END; $$; +GRANT CREATE ON SCHEMA public TO orgmetra_assignment_employment_guard_owner; +ALTER FUNCTION public.guard_assignment_employment_coverage() + OWNER TO orgmetra_assignment_employment_guard_owner; +ALTER FUNCTION public.guard_assignment_employment_coverage() + SECURITY DEFINER; +REVOKE CREATE ON SCHEMA public FROM orgmetra_assignment_employment_guard_owner; +REVOKE ALL ON FUNCTION public.guard_assignment_employment_coverage() FROM PUBLIC; + CREATE TRIGGER assignment_employment_coverage_guard BEFORE INSERT ON public.assignment_record FOR EACH ROW EXECUTE FUNCTION public.guard_assignment_employment_coverage(); +COMMENT ON FUNCTION public.guard_assignment_employment_coverage() IS + 'Serializes Assignment INSERT against Employment separation using the shared Employment anchor, then proves the inserted Assignment interval remains fully covered by a current active/leave Employment version. The SECURITY DEFINER owner is a dedicated NOLOGIN/NOBYPASSRLS role with only tenant-scoped read and anchor-lock capability.'; + COMMIT; From ef5facd414cae33abaa95bb72f24d505de3178f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:09:55 +0900 Subject: [PATCH 210/269] test(people): assert least-privilege assignment guard --- .../test_assignment_separation_anchor_lock_regression.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py b/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py index a1a4c3b07..b60937f6f 100644 --- a/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py +++ b/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py @@ -13,7 +13,12 @@ def test_assignment_insert_uses_employment_anchor_as_shared_conflict_boundary() """Keep the Assignment/Employment race guard at the authoritative PostgreSQL boundary.""" sql = _MIGRATION.read_text(encoding="utf-8") + assert "CREATE ROLE orgmetra_assignment_employment_guard_owner" in sql + assert "NOLOGIN" in sql + assert "NOBYPASSRLS" in sql + assert "GRANT UPDATE (recorded_from) ON TABLE public.employment_record" in sql assert "CREATE FUNCTION public.guard_assignment_employment_coverage()" in sql + assert "SECURITY DEFINER" in sql assert "FOR UPDATE OF employment" in sql assert "CREATE TRIGGER assignment_employment_coverage_guard" in sql assert "BEFORE INSERT ON public.assignment_record" in sql From dba7ff5a5adffffb332e9c13140d58aa8e388940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:10:33 +0900 Subject: [PATCH 211/269] docs(people): define assignment separation conflict boundary --- ...verned-employment-separation-transition.md | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/adr/0015-governed-employment-separation-transition.md b/docs/adr/0015-governed-employment-separation-transition.md index 2a089e8bf..25fa95751 100644 --- a/docs/adr/0015-governed-employment-separation-transition.md +++ b/docs/adr/0015-governed-employment-separation-transition.md @@ -13,6 +13,8 @@ Two fields could otherwise become competing termination truths: `effective_to` o The persistence function is also a high-impact database capability. Revoking PUBLIC execution while leaving it `SECURITY INVOKER` is not a complete runtime boundary: a service principal would need the underlying People and audit/outbox DML rights merely to invoke the function, which would let that principal bypass the governed transition with direct SQL. +A further cross-command race exists if Assignment creation and Employment separation do not share one aggregate conflict boundary. Separation already locks `employment_record` before checking Assignment truth, but an Assignment writer that only reads Employment versions can validate an active version and insert after that check. Conversely, an Assignment read begun before a separation lock is released can retain a READ COMMITTED statement snapshot that predates the newly committed terminal version. The invariant therefore cannot be protected by application-level validation or by adding a row lock to the same version-read statement alone. + ## Constraints - Person identity and prior Employment identity/history must remain immutable. @@ -21,8 +23,10 @@ The persistence function is also a high-impact database capability. Revoking PUB - A high-impact separation requires explicit actor, purpose, reason, evidence and human confirmation. - Tenant context must be bound before acquiring database-global advisory coordination state. - Assignment lifecycle is owned by the Assignment boundary. Separation may not silently rewrite or close Assignment facts. +- Assignment creation and Employment separation must serialize on the same Employment aggregate before either can establish contradictory durable truth. - Rehire uses a new Employment identity unless a later, separately reviewed contract explicitly establishes another rule. - The externally assignable separation capability must not carry direct People/audit/outbox table DML or RLS-bypass authority. +- Ordinary Assignment writers must not receive broad Employment UPDATE authority merely to participate in aggregate serialization. ## Considered alternatives @@ -42,6 +46,18 @@ Rejected. Overlapping business intervals would make the current Employment state Rejected. It crosses aggregate ownership and makes one Employment transaction responsible for Assignment policy and recovery. Open or future-effective Assignments instead cause the separation command to fail closed until their owner coordinates them. +### Validate Assignment coverage only in the application adapter + +Rejected. Direct database writers and a concurrent separation can bypass or invalidate a pre-insert application observation. The invariant is relational and must remain correct at the authoritative database boundary. + +### Add `FOR UPDATE` to the existing Assignment Employment-version read + +Rejected as the sole repair. Under READ COMMITTED, a statement takes its snapshot before it waits for a conflicting row lock. After the wait it can therefore continue from a pre-separation version snapshot. The writer needs a separate Employment-anchor lock statement followed by a fresh coverage read, or an equivalent database-owned boundary. + +### Grant the ordinary Assignment writer UPDATE privilege on Employment solely for row locking + +Rejected. PostgreSQL requires UPDATE privilege for `SELECT ... FOR UPDATE`; widening the normal writer for a coordination mechanism would enlarge its DML capability without a business mutation need. + ### Keep the separation function as SECURITY INVOKER and grant the service its table privileges Rejected. The application would then hold direct `employment_record_version`, separation/idempotency and audit/outbox mutation capabilities outside the reviewed function contract. Revoking PUBLIC function execution would not prevent bypass of its tenant, replay, evidence and history rules. @@ -65,32 +81,42 @@ A separation is one governed correction of an exact current-known Employment ver 9. Rehire, when implemented under #302, must create a new Employment for the existing Person and cite a successfully separated prior Employment. It must not reopen the terminated Employment or infer authority from an old candidate-worker conversion. 10. The database execution boundary is capability-separated. `orgmetra_employment_separation_owner` is a dedicated `NOLOGIN`/`NOBYPASSRLS` owner of the `SECURITY DEFINER` function and receives only the reviewed People/audit/outbox privileges needed by that transaction. `orgmetra_employment_separation_executor` is a distinct `NOLOGIN`/`NOBYPASSRLS` role with schema `USAGE` and function `EXECUTE` only. An application login may be granted the executor capability operationally; it must not be granted the owner role or direct table DML as a substitute. 11. The SECURITY DEFINER boundary retains the fixed `pg_catalog, public, pg_temp` search path, explicit tenant-context check and FORCE RLS. Ownership transfer receives `CREATE` on `public` only inside the atomic migration and revokes it before commit. +12. Assignment INSERT and separation share `employment_record` as their conflict boundary. `assignment_employment_coverage_guard` locks the exact Employment anchor first, then performs a separate post-lock read of current recorded Employment versions and allows the Assignment only when an `active` or `leave` interval fully covers the proposed Assignment interval. This makes both commit orders safe: Assignment-first makes separation re-observe and reject the durable Assignment; separation-first makes Assignment re-observe and reject the terminal Employment state. +13. The Assignment guard executes as SECURITY DEFINER under a dedicated `orgmetra_assignment_employment_guard_owner` role that is `NOLOGIN` and `NOBYPASSRLS`. It receives only schema usage, tenant-scoped Employment/version SELECT, `current_tenant_record_id()` execution, and the minimal `employment_record.recorded_from` UPDATE capability PostgreSQL requires for the anchor row lock. The ordinary Assignment writer does not receive this lock privilege. PUBLIC execution is revoked. +14. Historical Assignment facts ending at or before the separation boundary remain legal. The guard prevents contradictory current/future truth; it does not rewrite or erase past Assignment history. ## Data ownership `employment_record` remains the durable Employment identity. `employment_record_version` remains the bitemporal business-fact history. `employment_separation_record` stores PII-minimized decision provenance linking the prior version, optional continuation version, terminal version, governed decision metadata and audit event. It is append-only, tenant-qualified and protected by forced RLS. +`assignment_record` remains Assignment-owned truth. Migration `0017_assignment_employment_separation_serialization.sql` adds only a database conflict/coverage guard at Assignment INSERT; separation still does not create, close, rewrite or delete Assignment rows. + No cross-service SQL or copied HR truth is introduced. Keyverse remains the identity/policy backend; external workflow, payroll, identity deprovisioning and notification work belongs after the transaction through owned contracts/events. -The separation owner/executor roles are database capabilities, not HR identities. They do not replace Keyverse authentication/authorization, Person identity, Employment truth or human confirmation. +The separation owner/executor and Assignment guard-owner roles are database capabilities, not HR identities. They do not replace Keyverse authentication/authorization, Person identity, Employment truth or human confirmation. ## Failure and concurrency semantics -A stale expected version, wrong Person/Employment binding, wrong tenant context, semantic idempotency conflict, future Employment version, or Assignment requiring coordination fails before any durable separation state commits. Exact-key concurrent requests serialize on PostgreSQL advisory transaction state and converge on one first result plus replay. A test is acceptable only when the second backend is observed waiting on the first through PostgreSQL's lock graph; elapsed time alone is not serialization evidence. +A stale expected version, wrong Person/Employment binding, wrong tenant context, semantic idempotency conflict, future Employment version, or Assignment requiring coordination fails before any durable separation state commits. Exact-key concurrent requests serialize on PostgreSQL advisory transaction state and converge on one first result plus replay. Distinct separation requests serialize on the Employment anchor. + +Assignment/separation races use the same Employment row lock in both directions. Acceptance requires the blocked backend to expose the winning backend through PostgreSQL's lock graph with a row/transaction lock wait; elapsed time alone is not proof. After the blocker commits, the waiter must re-evaluate current database truth and either proceed consistently or fail closed. A committed Assignment extending through separation must make separation fail; a committed separation must make a conflicting Assignment fail. Durable postconditions must show no loser-side contradictory fact. -Capability migration fails before project-object elevation if either reserved separation role name already exists. This prevents an existing role with undisclosed membership/ACL state from being silently reused as the privileged owner or executor. +Capability migrations fail before project-object elevation if reserved capability role names already exist. This prevents an existing role with undisclosed membership/ACL state from being silently reused as a privileged owner. ## Evidence required before Accepted -- PostgreSQL contract applies migrations through `0016_employment_separation_executor_capability.sql` on PostgreSQL 16. +- PostgreSQL contract applies migrations through `0017_assignment_employment_separation_serialization.sql` on PostgreSQL 16. - Current knowledge contains one pre-separation continuation and one terminal version without effective overlap, while an earlier knowledge coordinate still returns the pre-correction active/leave fact. - Audit event `time` equals the database-owned separation `recorded_at` and audit/outbox/idempotency/separation facts are one-transaction durable. - Same-key replay returns the first terminal version and timestamp; changed semantics under the key are rejected. - Cross-tenant, stale-version, future-version and open-Assignment hostile cases fail closed. - Concurrent exact-key first attempts expose the real PostgreSQL advisory-lock blocker relationship and converge on one durable separation. -- PUBLIC and an unrelated `NOLOGIN`/`NOBYPASSRLS` probe cannot execute the function. -- The dedicated executor can cross the function boundary but has no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE capability on the governed People/audit/outbox relations. -- The function is owned by the dedicated `NOLOGIN`/`NOBYPASSRLS` owner and executes as SECURITY DEFINER while FORCE RLS remains effective under the caller-supplied tenant context. -- Canonical Foundation owner registers both focused PostgreSQL contracts without duplicating workflow ownership and exact-head hosted evidence is green. +- Distinct-key separation attempts expose the shared Employment row/transaction blocker and leave one winner plus one stale-version loser. +- Assignment-first and separation-first interleavings both expose the Employment anchor as the database blocker and converge on exactly one internally consistent outcome; historical Assignment ending at the separation boundary remains accepted. +- PUBLIC and an unrelated `NOLOGIN`/`NOBYPASSRLS` probe cannot execute the separation function. +- The dedicated separation executor can cross the function boundary but has no direct SELECT/INSERT/UPDATE/DELETE/TRUNCATE capability on the governed People/audit/outbox relations. +- The separation function is owned by the dedicated `NOLOGIN`/`NOBYPASSRLS` owner and executes as SECURITY DEFINER while FORCE RLS remains effective under the caller-supplied tenant context. +- The Assignment guard is owned by its dedicated `NOLOGIN`/`NOBYPASSRLS` role, ordinary Assignment runtime authority is not widened for anchor locking, and FORCE RLS still scopes its post-lock Employment coverage read. +- Canonical Foundation owner registers the focused PostgreSQL roots/companions without duplicating workflow ownership and exact-head hosted evidence is green. Until those conditions are present on the protected stack, this ADR remains Proposed. From 0d0b18ef9204077463d044073c92722d455dcd14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:11:02 +0900 Subject: [PATCH 212/269] docs(people): trace assignment separation serialization --- docs/traceability/employment-separation.md | 28 +++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md index c927fa3f5..89715163d 100644 --- a/docs/traceability/employment-separation.md +++ b/docs/traceability/employment-separation.md @@ -10,7 +10,9 @@ This trace binds issue #314 to the first authoritative Employment separation sli | One authoritative separation fact | ADR 0015 | `employment_separation_record` links prior, optional continuation and terminal Employment versions; `terminated` successor is the terminal fact while continuation `effective_to` is only its interval boundary | implemented_on_active_pr | | Exact tenant/Person/Employment/version binding | #314 + ADR 0015 | `separate_employment_record_once(...)` checks tenant context before advisory locking, locks the Employment anchor and requires the expected current-recorded version | implemented_on_active_pr | | Future Employment facts are not silently cancelled | ADR 0015 | any other current-known Employment version overlapping `[separation_effective_on, infinity)` fails closed and requires owner coordination | implemented_on_active_pr | -| Assignment ownership is preserved | Context Map + ADR 0015 | any Assignment still effective on/after separation fails closed; separation never rewrites Assignment rows | implemented_on_active_pr | +| Assignment ownership is preserved | Context Map + ADR 0015 | separation never rewrites Assignment rows; an Assignment still effective on/after separation blocks the transition | implemented_on_active_pr | +| Assignment/separation cross-command serialization | #314 acceptance #6 + ADR 0015 | `database/migrations/0017_assignment_employment_separation_serialization.sql` adds an Assignment INSERT guard that shares the exact Employment anchor lock with separation, then re-reads current active/leave Employment coverage in a separate post-lock statement; `tests/test_assignment_separation_serialization_postgres.sh` exercises Assignment-first and separation-first interleavings through `pg_blocking_pids(...)` | implemented_on_active_pr; awaiting_exact_head_green | +| Assignment guard least privilege | ADR 0015 | `orgmetra_assignment_employment_guard_owner` is `NOLOGIN`/`NOBYPASSRLS`; the SECURITY DEFINER trigger function receives tenant-scoped Employment/version reads plus only the anchor-column UPDATE privilege PostgreSQL requires for `FOR UPDATE`, while ordinary Assignment runtime authority is not widened | implemented_on_active_pr; awaiting_capability_acceptance | | Database-owned recorded time | #314 | one post-lock `clock_timestamp()` closes prior recorded history, opens replacement versions, stamps separation provenance and is serialized into the audit event `time` | implemented_on_active_pr | | High-impact governance evidence | ADR 0006 + ADR 0008 + ADR 0015 | controlled reason, evidence reference/version, actor, `workforce_admin` purpose and human confirmation are mandatory; audit/outbox is persisted in the same transaction | implemented_on_active_pr | | Retry safety | People mutation idempotency contract | exact tenant+route+idempotency-key advisory lock; same semantic command replays first terminal version/timestamp; changed command under same key fails | implemented_on_active_pr | @@ -26,7 +28,7 @@ This trace binds issue #314 to the first authoritative Employment separation sli | Distinct-key Employment conflict serialization | #314 acceptance #5 | `tests/test_employment_separation_distinct_key_concurrency.sh` holds the first successful mutation at a shell-controlled pre-commit FIFO boundary, races a different idempotency key against the same Employment/version, requires `pg_blocking_pids(...)` plus a row/transaction lock wait rather than an advisory wait, then requires one committed separation and a stale-version loser with no loser-side separation/audit/outbox/idempotency truth | implemented_on_active_pr; awaiting_foundation_registration | | Failure cleanup and transaction rollback | #314 acceptance #8 | `tests/test_employment_separation_failure_cleanup.sh` establishes an observable distinct-key blocker, terminates the blocked loser before the pre-commit winner, waits both client processes, requires both named PostgreSQL backends to disappear from `pg_stat_activity`, and then proves zero separation/audit/outbox/idempotency residue while the original active Employment version remains current | implemented_on_active_pr; awaiting_foundation_registration | | Hostile cases | #314 | focused PostgreSQL contract covers cross-tenant context, stale expected version, future-version coordination, open Assignment, same-key semantic conflict and earlier-knowledge reconstruction | awaiting_foundation_registration | -| Canonical CI ownership | #311 | the separation root and capability root plus the distinct-key, failure-cleanup and uncertain-commit companions must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | +| Canonical CI ownership | #311 | the separation root/capability root, existing separation companions, and the Assignment/separation serialization root must be registered by the Foundation owner after stack reconciliation; this People writer does not duplicate or weaken `.github/workflows/foundation-ci.yml` | awaiting_foundation_registration | | Buyer HTTP boundary | #314 | `EmploymentSeparationAsgiApp` authenticates and binds tenant/actor before the body is accepted, requires `workforce_admin`, preserves expected-version/date/reason/evidence/confirmation/idempotency semantics, generates server-owned audit/outbox identities, returns replay plus DB-owned recorded time, and sanitizes backend failures | implemented_on_active_pr | | Canonical OpenAPI route contract | #314 | `schemas/openapi.yaml` publishes `POST /v1/employment-separations`; focused route/schema regression verifies the exact request/result/error surface | implemented_on_active_pr | | Buyer-path performance journey | #314 | the published route still needs realistic async E2E/k6 measurement against the operational persistence capability before any p95 claim is made | planned | @@ -34,24 +36,28 @@ This trace binds issue #314 to the first authoritative Employment separation sli ## Current acceptance boundary -`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, ADR 0015, the application/persistence ports, the buyer-facing ASGI route, canonical OpenAPI contract, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing database capability is separated from the privileged function owner, so an application principal does not need direct People/audit/outbox DML merely to invoke the transition. +`database/migrations/0014_employment_separation_transition.sql`, `0015_employment_separation_capability_hardening.sql`, `0016_employment_separation_executor_capability.sql`, `0017_assignment_employment_separation_serialization.sql`, ADR 0015, the application/persistence ports, the buyer-facing ASGI route, canonical OpenAPI contract, and the focused PostgreSQL contracts are ordinary-forward changes on the canonical People PR. The runtime-facing separation capability is separated from the privileged function owner, and the Assignment/separation guard uses a separate non-login owner rather than widening the ordinary Assignment writer solely for PostgreSQL row-lock privilege. -`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision, issues no direct People or audit/outbox DML, and validates returned persistence evidence before transaction-context exit so rejected evidence can still force rollback. Trusted persistence/authorization/result-integrity faults use `EmploymentSeparationPersistenceIntegrityError`; reviewed authoritative-state conflicts retain `EmploymentSeparationIntegrityError`. `separation_http.py` maps the former to sanitized operator-actionable 500 responses and the latter to client-resolvable 409 responses, so buyers are not told to refresh or change an idempotency key when the service itself returned corrupted evidence. Server-generated audit/outbox identity failure is likewise operational and is not misclassified as invalid buyer input. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. +The cross-command race is treated as a database invariant. Separation takes `employment_record` before checking Assignment truth. Assignment INSERT now takes that same anchor before a separate current-version coverage read. Because the coverage query runs after the potentially blocking lock statement, READ COMMITTED observes a fresh snapshot after a concurrent separation commits. If Assignment commits first, the waiting separation sees the newly durable Assignment and fails closed; if separation commits first, the waiting Assignment sees the terminal Employment state and fails closed. A historical Assignment whose half-open interval ends exactly at the separation boundary remains admissible. -The existing Foundation workflow remains owned by its canonical Foundation stack. The new PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. +`services/people-api/src/orgmetra_people_api/separation.py` is the application authorization boundary. It does not trust caller-owned identity aliases or backend result identity. `postgres_separation.py` consumes the already-authorized decision, issues no direct People or audit/outbox DML, and validates returned persistence evidence before transaction-context exit so rejected evidence can still force rollback. Trusted persistence/authorization/result-integrity faults use `EmploymentSeparationPersistenceIntegrityError`; reviewed authoritative-state conflicts retain `EmploymentSeparationIntegrityError`. `separation_http.py` maps the former to sanitized operator-actionable 500 responses and the latter to client-resolvable 409 responses. Operational deployment still has to bind the People database login to the released executor capability; the adapter does not assume or switch into the owner role. -Distinct-key Employment concurrency now has a focused executable companion rather than a timing-only acceptance note. The first backend reaches a successful mutation and is kept inside its transaction by a shell-controlled FIFO, not by a database sleep. Only then is a second backend started with a different idempotency key but the same Employment and expected version. Acceptance requires the second backend to expose the first through `pg_blocking_pids(...)` while waiting on a row/transaction lock, explicitly not the per-key advisory lock. Releasing the first transaction must yield one committed separation and a stale-version loser; separation, audit event, outbox delivery and idempotency truth are counted afterward and the losing key must have no durable idempotency row. This artifact is not called hosted GREEN until #311 registers it as a companion and executes it from the immutable exact-candidate tree. +The existing Foundation workflow remains owned by its canonical Foundation stack. New PostgreSQL contracts are therefore not treated as hosted GREEN until that owner discovers them from the exact candidate tree and an exact-head PostgreSQL run passes. Static repository validation and bot statuses that report skipped review are not runtime acceptance or qualifying independent approval. -Failure cleanup is now a separate executable acceptance rather than an assumption inherited from normal-path `wait` calls. The cleanup companion first establishes the same real Employment blocker, then terminates the blocked loser while the winning transaction is still held pre-commit, waits for that server session to disappear, terminates the winner, closes the FIFO, waits both client processes, and requires the named server-session count to reach zero before reading durable state. The elapsed polling interval is coordination only; `pg_stat_activity` quiescence plus zero separation/audit/outbox/idempotency residue and restoration of the original current Employment version are the acceptance evidence. This companion is likewise pending canonical #311 registration and exact-tree execution. +Distinct-key Employment concurrency has a focused executable companion rather than a timing-only acceptance note. The first backend reaches a successful mutation and is kept inside its transaction by a shell-controlled FIFO. Only then is a second backend started with a different idempotency key but the same Employment and expected version. Acceptance requires the second backend to expose the first through `pg_blocking_pids(...)` while waiting on a row/transaction lock, explicitly not the per-key advisory lock. Releasing the first transaction must yield one committed separation and a stale-version loser; separation, audit event, outbox delivery and idempotency truth are counted afterward and the losing key must have no durable idempotency row. -Uncertain commit is qualified separately from ordinary same-key replay. `tests/test_employment_separation_uncertain_commit_recovery.sh` creates an isolated Employment, performs the separation and COMMIT on the first connection, then deliberately keeps that backend alive in `pg_sleep`. A second connection must first observe the separation/idempotency/audit/outbox rows as durable; only that external database evidence qualifies the commit. The first backend is then terminated so the original client process reports failure despite the already-committed transaction. A fresh connection retries the same semantic command and idempotency key with new server-generated audit/outbox identities. Acceptance requires replay of the exact first terminal version and `recorded_at`, exactly one separation/idempotency/audit/outbox truth set, and zero rows for the retry-only audit/outbox identities. The first client's output is not recovery evidence. This companion is pending canonical #311 registration and exact-tree PostgreSQL execution. +Failure cleanup is a separate executable acceptance. The cleanup companion first establishes the same real Employment blocker, then terminates the blocked loser while the winning transaction is still held pre-commit, waits for that server session to disappear, terminates the winner, closes the FIFO, waits both client processes, and requires the named server-session count to reach zero before reading durable state. The elapsed polling interval is coordination only; `pg_stat_activity` quiescence plus zero separation/audit/outbox/idempotency residue and restoration of the original current Employment version are the acceptance evidence. + +Uncertain commit is qualified separately from ordinary same-key replay. `tests/test_employment_separation_uncertain_commit_recovery.sh` creates an isolated Employment, performs the separation and COMMIT on the first connection, then deliberately keeps that backend alive in `pg_sleep`. A second connection must first observe the separation/idempotency/audit/outbox rows as durable; only that external database evidence qualifies the commit. The first backend is then terminated so the original client process reports failure despite the already-committed transaction. A fresh connection retries the same semantic command and idempotency key with new server-generated audit/outbox identities. Acceptance requires replay of the exact first terminal version and `recorded_at`, exactly one separation/idempotency/audit/outbox truth set, and zero rows for the retry-only audit/outbox identities. + +`tests/test_assignment_separation_serialization_postgres.sh` adds the missing two-command interleaving evidence. It requires Assignment-first and separation-first sessions to expose each other through `pg_blocking_pids(...)` with a row/transaction lock wait, then checks durable truth after release. The first ordering must leave the Assignment and no separation; the reverse ordering must leave the separation and no conflicting Assignment. The final historical-boundary case prevents a false repair that would reject an Assignment ending exactly when separation starts. This new root is not called hosted GREEN until the canonical Foundation owner admits and executes it from the immutable candidate tree. ## Next owner handoff -Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh` and `tests/test_employment_separation_capability_postgres.sh` as PostgreSQL roots and bind `tests/test_employment_separation_distinct_key_concurrency.sh`, `tests/test_employment_separation_failure_cleanup.sh`, and `tests/test_employment_separation_uncertain_commit_recovery.sh` as reviewed companions of the separation root, rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. +Foundation reconciliation should discover `tests/test_employment_separation_postgres.sh`, `tests/test_employment_separation_capability_postgres.sh`, and `tests/test_assignment_separation_serialization_postgres.sh` as PostgreSQL roots and bind `tests/test_employment_separation_distinct_key_concurrency.sh`, `tests/test_employment_separation_failure_cleanup.sh`, and `tests/test_employment_separation_uncertain_commit_recovery.sh` as reviewed companions of the separation root, rather than adding filename-specific workflow branches. The execution environment must retain the exact-candidate tree, scrubbed environment, tenant-scoped runtime and immutable provenance rules already owned by #311. -The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. Keyverse remains the user/actor authorization backend; the database executor role is only the persistence capability beneath the already-authorized high-impact command. +The People runtime owner must bind its application/database login to `orgmetra_employment_separation_executor` (or an operationally equivalent grant of that released capability) rather than the owner role or direct table DML. The Assignment guard owner is not an application role and must remain `NOLOGIN`/`NOBYPASSRLS`; its narrow anchor-lock capability exists only inside the database trigger boundary. Keyverse remains the user/actor authorization backend. The remaining buyer-visible route gap is realistic buyer-path p95/E2E evidence under the deployed executor capability. No performance claim is made from unit tests, static schema checks, or queued CI alone. -Once those owner boundaries are reconciled, all separation companions have exact-head hosted PostgreSQL evidence, and the remaining security/review gates are green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. +Once those owner boundaries are reconciled, all separation/serialization contracts have exact-head hosted PostgreSQL evidence, and the remaining security/review gates are green, the People stack can decide whether ADR 0015 may move from Proposed to Accepted and whether #314 is ready to hand off to #302. From d565ed958465041d00be85d584f14a56d54f0117 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:18:13 +0900 Subject: [PATCH 213/269] fix(foundation): provenance-index assignment separation artifacts --- tests/validate_repository.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/validate_repository.py b/tests/validate_repository.py index f37049c85..a4e5f6fb6 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -73,6 +73,7 @@ "database/migrations/0014_employment_separation_transition.sql", "database/migrations/0015_employment_separation_capability_hardening.sql", "database/migrations/0016_employment_separation_executor_capability.sql", + "database/migrations/0017_assignment_employment_separation_serialization.sql", "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", "packages/hris-kernel/tests/test_audit_outbox.py", "schemas/openapi.yaml", @@ -81,6 +82,7 @@ "tests/dispatcher-inventory.test.mjs", "tests/foundation-contract.test.mjs", "tests/openapi-contract.test.mjs", + "tests/test_assignment_separation_serialization_postgres.sh", "tests/test_bitemporal_postgres.sh", "tests/test_tenant_isolation_postgres.sh", "tests/test_evidence_sealing_postgres.sh", From c110547083f44902eab60308e442d2b048a4a46a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:21:52 +0900 Subject: [PATCH 214/269] fix(foundation): regenerate exact provenance manifest --- manifest.json | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 6de0c15f8..8967aede3 100644 --- a/manifest.json +++ b/manifest.json @@ -153,6 +153,12 @@ "bytes": 5372, "lines": 179 }, + { + "path": "database/migrations/0017_assignment_employment_separation_serialization.sql", + "sha256": "115e19bd6fa83d5715ed64b40790543723a5f588b1563b0b6c8ccc1310779848", + "bytes": 4703, + "lines": 116 + }, { "path": "docs/API_CONTRACT.md", "sha256": "63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589", @@ -405,6 +411,12 @@ "bytes": 7236, "lines": 210 }, + { + "path": "tests/test_assignment_separation_serialization_postgres.sh", + "sha256": "899b21b5472176b79c0651d39ebcaccf7d0374ddc38f2172191172df2f44b638", + "bytes": 16002, + "lines": 380 + }, { "path": "tests/test_audit_outbox_hardening_postgres.sh", "sha256": "518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0", @@ -497,9 +509,9 @@ }, { "path": "tests/validate_repository.py", - "sha256": "e6dd1cd0684f9e4b4118335ae353aeba6fe205e414080eccb6a6ffce52fb36a1", - "bytes": 28048, - "lines": 654 + "sha256": "50370ee20344d412682101c1eedb300876f7299fa6b2b5536cb6db38aee1b710", + "bytes": 28197, + "lines": 656 } ] } From 9a649332543a97990d10996d891e7ac1d206916b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:32:36 +0900 Subject: [PATCH 215/269] fix(foundation): reconcile Node provenance inventory --- scripts/foundation-contract-core.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 8830fe229..9f897a18c 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -70,6 +70,7 @@ export const REQUIRED_FILES = Object.freeze([ 'database/migrations/0014_employment_separation_transition.sql', 'database/migrations/0015_employment_separation_capability_hardening.sql', 'database/migrations/0016_employment_separation_executor_capability.sql', + 'database/migrations/0017_assignment_employment_separation_serialization.sql', 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', 'packages/hris-kernel/tests/test_audit_outbox.py', 'schemas/openapi.yaml', @@ -78,6 +79,7 @@ export const REQUIRED_FILES = Object.freeze([ 'tests/dispatcher-inventory.test.mjs', 'tests/foundation-contract.test.mjs', 'tests/openapi-contract.test.mjs', + 'tests/test_assignment_separation_serialization_postgres.sh', 'tests/test_bitemporal_postgres.sh', 'tests/test_tenant_isolation_postgres.sh', 'tests/test_evidence_sealing_postgres.sh', From 77c8e027035f1118ec7b12e01e2855eb2a0a1e55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 06:36:33 +0900 Subject: [PATCH 216/269] fix(foundation): reseal reconciled Node inventory --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 8967aede3..71ffe3ca3 100644 --- a/manifest.json +++ b/manifest.json @@ -383,9 +383,9 @@ }, { "path": "scripts/foundation-contract-core.mjs", - "sha256": "ee8d988efe848e57f4fbaf30112d262a84e9e230fe8e8d41ece6b525943b19c2", - "bytes": 28452, - "lines": 693 + "sha256": "40fd2811107590e72a251a5863d100c0c98d0dea57c0a89b4530ee5caa258a46", + "bytes": 28597, + "lines": 695 }, { "path": "scripts/foundation-contract.mjs", From aa277fbc61b80448dc4498ebe22f60f759250576 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:08:03 +0900 Subject: [PATCH 217/269] test(people): require exact conversion Employment binding --- ...onversion_employment_binding_regression.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 services/people-api/tests/test_conversion_employment_binding_regression.py diff --git a/services/people-api/tests/test_conversion_employment_binding_regression.py b/services/people-api/tests/test_conversion_employment_binding_regression.py new file mode 100644 index 000000000..f0f04a72e --- /dev/null +++ b/services/people-api/tests/test_conversion_employment_binding_regression.py @@ -0,0 +1,20 @@ +"""Regression contract for exact candidate-conversion Employment provenance.""" + +from __future__ import annotations + +import unittest + +from orgmetra_people_api.postgres_mutations import _CONVERSION_SQL + + +class ConversionEmploymentBindingRegressionTests(unittest.TestCase): + """Keep candidate conversion authority bound to the exact Employment aggregate.""" + + def test_conversion_lookup_binds_exact_employment_identity(self) -> None: + """Reject a Person-only conversion lookup that can authorize a different Employment.""" + self.assertIn("AND conversion.employment_record_id = %s", _CONVERSION_SQL) + self.assertEqual(_CONVERSION_SQL.count("%s"), 3) + + +if __name__ == "__main__": # pragma: no cover - direct local invocation only + unittest.main() From c6f72b0ede7273f1c525b588b1758a3d10d854fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:11:27 +0900 Subject: [PATCH 218/269] fix(people): bind conversion to exact Employment --- .../orgmetra_people_api/postgres_mutations.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index b90ebcf83..f118f4226 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -61,6 +61,7 @@ FROM public.candidate_worker_conversion_record AS conversion WHERE conversion.tenant_record_id = %s AND conversion.person_record_id = %s + AND conversion.employment_record_id = %s AND conversion.recorded_to IS NULL LIMIT 2 FOR UPDATE OF conversion @@ -600,7 +601,14 @@ def create_employment( employment_record_id=replayed_record_id, replay_command_digest=replay_digest, ) - cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) + cursor.execute( + _CONVERSION_SQL, + ( + command.tenant_record_id, + command.person_record_id, + command.employment_record_id, + ), + ) _require_one_conversion(cursor.fetchmany(2)) recorded_at = _post_lock_recorded_at(cursor) cursor.execute( @@ -816,7 +824,14 @@ def create_assignment( assignment_record_id=replayed_record_id, replay_command_digest=replay_digest, ) - cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) + cursor.execute( + _CONVERSION_SQL, + ( + command.tenant_record_id, + command.person_record_id, + command.employment_record_id, + ), + ) _require_one_conversion(cursor.fetchmany(2)) cursor.execute( _NAMED_EMPLOYMENT_VERSIONS_SQL, From 6ea491be9cf1e74b7f848becee9f4345170861d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:17:11 +0900 Subject: [PATCH 219/269] revert(people): drop circular pre-insert conversion binding --- .../orgmetra_people_api/postgres_mutations.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index f118f4226..b90ebcf83 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -61,7 +61,6 @@ FROM public.candidate_worker_conversion_record AS conversion WHERE conversion.tenant_record_id = %s AND conversion.person_record_id = %s - AND conversion.employment_record_id = %s AND conversion.recorded_to IS NULL LIMIT 2 FOR UPDATE OF conversion @@ -601,14 +600,7 @@ def create_employment( employment_record_id=replayed_record_id, replay_command_digest=replay_digest, ) - cursor.execute( - _CONVERSION_SQL, - ( - command.tenant_record_id, - command.person_record_id, - command.employment_record_id, - ), - ) + cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) _require_one_conversion(cursor.fetchmany(2)) recorded_at = _post_lock_recorded_at(cursor) cursor.execute( @@ -824,14 +816,7 @@ def create_assignment( assignment_record_id=replayed_record_id, replay_command_digest=replay_digest, ) - cursor.execute( - _CONVERSION_SQL, - ( - command.tenant_record_id, - command.person_record_id, - command.employment_record_id, - ), - ) + cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) _require_one_conversion(cursor.fetchmany(2)) cursor.execute( _NAMED_EMPLOYMENT_VERSIONS_SQL, From 20e29962311b17eb004412cd775dd5ada0ca4cba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:17:50 +0900 Subject: [PATCH 220/269] test(people): reject conversion as generic Employment authority --- ...st_conversion_employment_binding_regression.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/services/people-api/tests/test_conversion_employment_binding_regression.py b/services/people-api/tests/test_conversion_employment_binding_regression.py index f0f04a72e..1d20094d1 100644 --- a/services/people-api/tests/test_conversion_employment_binding_regression.py +++ b/services/people-api/tests/test_conversion_employment_binding_regression.py @@ -1,19 +1,20 @@ -"""Regression contract for exact candidate-conversion Employment provenance.""" +"""Regression contract for candidate-conversion versus People mutation authority.""" from __future__ import annotations +import inspect import unittest -from orgmetra_people_api.postgres_mutations import _CONVERSION_SQL +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort class ConversionEmploymentBindingRegressionTests(unittest.TestCase): - """Keep candidate conversion authority bound to the exact Employment aggregate.""" + """Keep recruiting provenance out of generic Employment authorization.""" - def test_conversion_lookup_binds_exact_employment_identity(self) -> None: - """Reject a Person-only conversion lookup that can authorize a different Employment.""" - self.assertIn("AND conversion.employment_record_id = %s", _CONVERSION_SQL) - self.assertEqual(_CONVERSION_SQL.count("%s"), 3) + def test_generic_employment_creation_does_not_require_candidate_conversion(self) -> None: + """A future Employment cannot depend on a conversion whose FK already needs it.""" + source = inspect.getsource(PostgresPeopleMutationPort.create_employment) + self.assertNotIn("_CONVERSION_SQL", source) if __name__ == "__main__": # pragma: no cover - direct local invocation only From 7f9fbd08470e5c5ab9292469ca91f7e42c2d8dc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:34:09 +0900 Subject: [PATCH 221/269] fix(people): serialize generic Employment on Person anchor --- .../orgmetra_people_api/postgres_mutations.py | 57 ++++++++++++++++--- .../tests/test_postgres_people_mutations.py | 47 +++++++++------ 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index b90ebcf83..1ae25597e 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -1,9 +1,10 @@ """Atomic PostgreSQL adapter for governed People employment, position, and assignment writes. -The adapter writes only Orgmetra-owned canonical HRIS relations. Employment and -assignment paths require a current ``candidate_worker_conversion_record`` and -never insert ``candidate_worker_link``. Every accepted write calls -``record_audit_outbox_event`` in the same tenant-bound transaction. +The adapter writes only Orgmetra-owned canonical HRIS relations. Generic Employment +creation serializes on its current Person aggregate; Assignment still requires a +current ``candidate_worker_conversion_record`` and never inserts +``candidate_worker_link``. Every accepted write calls ``record_audit_outbox_event`` +in the same tenant-bound transaction. """ from __future__ import annotations @@ -54,6 +55,18 @@ _POSITION_FIELDS = frozenset({"position_record"}) _ASSIGNMENT_FIELDS = frozenset({"assignment_record"}) +_PERSON_EMPLOYMENT_ANCHOR_SQL = """ +SELECT + person.person_record_id, + pg_catalog.transaction_timestamp() +FROM public.person_record AS person +WHERE person.tenant_record_id = %s + AND person.person_record_id = %s + AND person.recorded_to IS NULL +LIMIT 2 +FOR UPDATE OF person +""".strip() + _CONVERSION_SQL = """ SELECT conversion.candidate_worker_conversion_record_id, @@ -514,6 +527,30 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass ) +def _require_one_person_employment_anchor( + rows: object, + *, + expected_person_record_id: UUID, +) -> None: + """Require and verify the current Person row that serializes Employment creation.""" + detached = _unpack_fixed_rows( + rows, + row_width=2, + error_message="person employment anchor row has an invalid shape", + ) + if not detached: + raise PeopleMutationNotFound("person record was not found") + if len(detached) != 1: + raise PeopleMutationIntegrityError("multiple person employment anchors matched the person") + person_record_id, anchor_time = detached[0] + if ( + not _is_operational_uuid(person_record_id) + or person_record_id != expected_person_record_id + or not _is_aware_datetime(anchor_time) + ): + raise PeopleMutationIntegrityError("person employment anchor identity is invalid") + + def _require_one_conversion(rows: object) -> tuple[UUID, datetime]: """Require exactly one current conversion row and a usable transaction timestamp.""" detached = _unpack_fixed_rows( @@ -577,7 +614,7 @@ def create_employment( command: EmploymentMutationCommand, authorization: AuthorizationDecision, ) -> EmploymentMutationResult: - """Persist one employment after conversion and exclusivity checks.""" + """Persist one Employment after Person serialization and exclusivity checks.""" if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") command = replace(command) @@ -600,8 +637,14 @@ def create_employment( employment_record_id=replayed_record_id, replay_command_digest=replay_digest, ) - cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) - _require_one_conversion(cursor.fetchmany(2)) + cursor.execute( + _PERSON_EMPLOYMENT_ANCHOR_SQL, + (command.tenant_record_id, command.person_record_id), + ) + _require_one_person_employment_anchor( + cursor.fetchmany(2), + expected_person_record_id=command.person_record_id, + ) recorded_at = _post_lock_recorded_at(cursor) cursor.execute( _EMPLOYMENT_VERSIONS_SQL, diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index 0175fc6d4..051332eee 100644 --- a/services/people-api/tests/test_postgres_people_mutations.py +++ b/services/people-api/tests/test_postgres_people_mutations.py @@ -189,7 +189,7 @@ def covering_position_row() -> tuple[object, ...]: class PostgresPeopleMutationTests(unittest.TestCase): - """Prove one tenant-bound transaction owns HRIS, conversion, and audit writes.""" + """Prove tenant-bound HRIS conflict anchors, provenance, and audit writes.""" def _port( self, @@ -207,8 +207,8 @@ def _port( connection = FakeConnection(cursor) return PostgresPeopleMutationPort(lambda: connection), cursor - def test_employment_requires_conversion_and_records_audit_atomically(self) -> None: - port, cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[]]) + def test_employment_serializes_on_person_and_records_audit_atomically(self) -> None: + port, cursor = self._port([[(PERSON, RECORDED_AT)]], [[]]) result = create_employment_record( principal=PRINCIPAL, command=employment_command(), @@ -218,11 +218,11 @@ def test_employment_requires_conversion_and_records_audit_atomically(self) -> No ) self.assertEqual(result.employment_record_id, EMPLOYMENT) sql_text = "\n".join(sql for sql, _parameters in cursor.executions) - self.assertIn("public.candidate_worker_conversion_record", sql_text) - conversion_sql = next( - sql for sql, _parameters in cursor.executions if "candidate_worker_conversion_record" in sql - ) - self.assertIn("conversion.recorded_to IS NULL", conversion_sql) + self.assertIn("public.person_record", sql_text) + person_lock_sql = next(sql for sql, _parameters in cursor.executions if "public.person_record" in sql) + self.assertIn("person.recorded_to IS NULL", person_lock_sql) + self.assertIn("FOR UPDATE OF person", person_lock_sql) + self.assertNotIn("public.candidate_worker_conversion_record", sql_text) self.assertIn("public.employment_record", sql_text) self.assertIn("employment_concurrency_code", sql_text) self.assertIn("public.record_audit_outbox_event", sql_text) @@ -290,17 +290,18 @@ def test_assignment_reuses_kernel_and_conversion_then_audits(self) -> None: self.assertIn("public.people_mutation_idempotency_record", sql_text) self.assertNotIn("candidate_worker_link", sql_text) - def test_missing_or_invalid_conversion_fails_before_insert(self) -> None: + def test_employment_missing_or_invalid_person_anchor_fails_before_insert(self) -> None: scenarios = ( [[]], - [[(CONVERSION, RECORDED_AT), (CONVERSION, RECORDED_AT)]], - [[(CONVERSION,)]], + [[(PERSON, RECORDED_AT), (PERSON, RECORDED_AT)]], + [[(PERSON,)]], [[(UUID(int=0), RECORDED_AT)]], + [[(CONVERSION, RECORDED_AT)]], ) for rows in scenarios: with self.subTest(rows=rows): port, cursor = self._port(rows) - with self.assertRaises(PeopleMutationIntegrityError): + with self.assertRaises((PeopleMutationIntegrityError, PeopleMutationNotFound)): create_employment_record( principal=PRINCIPAL, command=employment_command(), @@ -310,8 +311,20 @@ def test_missing_or_invalid_conversion_fails_before_insert(self) -> None: ) self.assertFalse(any("INSERT INTO public.employment_record" in sql for sql, _parameters in cursor.executions)) + def test_assignment_missing_conversion_fails_before_insert(self) -> None: + port, cursor = self._port([[]], [[], [], []]) + with self.assertRaisesRegex(PeopleMutationIntegrityError, "candidate-worker conversion"): + create_assignment_record( + principal=PRINCIPAL, + command=assignment_command(), + purpose_code="workforce_admin", + policy=assignment_policy(), + mutation_port=port, + ) + self.assertFalse(any("INSERT INTO public.assignment_record" in sql for sql, _parameters in cursor.executions)) + def test_invalid_existing_employment_row_fails_closed(self) -> None: - port, _cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[("bad",)]]) + port, _cursor = self._port([[(PERSON, RECORDED_AT)]], [[("bad",)]]) with self.assertRaisesRegex(PeopleMutationIntegrityError, "invalid shape"): create_employment_record( principal=PRINCIPAL, @@ -333,7 +346,7 @@ def test_overlapping_exclusive_employment_fails_closed(self) -> None: RECORDED_AT, None, ) - port, cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[existing]]) + port, cursor = self._port([[(PERSON, RECORDED_AT)]], [[existing]]) with self.assertRaises(PeopleMutationIntegrityError): create_employment_record( principal=PRINCIPAL, @@ -427,7 +440,7 @@ def test_forged_authorization_and_typed_commands_are_required(self) -> None: def factory() -> FakeConnection: nonlocal calls calls += 1 - return FakeConnection(ScriptedCursor([[(CONVERSION, RECORDED_AT)]], [[]])) + return FakeConnection(ScriptedCursor([[(PERSON, RECORDED_AT)]], [[]])) port = PostgresPeopleMutationPort(factory) with self.assertRaisesRegex(PeopleMutationIntegrityError, "authorization"): @@ -470,7 +483,7 @@ def test_invalid_version_cell_values_fail_closed(self) -> None: RECORDED_AT, None, ) - port, _cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[bad_employment]]) + port, _cursor = self._port([[(PERSON, RECORDED_AT)]], [[bad_employment]]) with self.assertRaisesRegex(PeopleMutationIntegrityError, "invalid"): create_employment_record( principal=PRINCIPAL, @@ -594,7 +607,7 @@ def test_different_key_is_a_new_command(self) -> None: other_audit = UUID("0198a412-8200-7000-8000-00000000008a") other_outbox = UUID("0198a412-8200-7000-8000-00000000008b") cursor = ScriptedCursor( - [[], [(CONVERSION, RECORDED_AT)], [], [(CONVERSION, RECORDED_AT)]], + [[], [(PERSON, RECORDED_AT)], [], [(PERSON, RECORDED_AT)]], [[], []], ) connection = FakeConnection(cursor) From 5eb7d1524e3a5992d7d575d93f260eb02bbe6061 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:36:34 +0900 Subject: [PATCH 222/269] test(people): bind recorded-time regression to Person anchor --- ...est_postgres_mutation_recorded_time_regression.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py b/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py index 24cc352f4..6243f23cf 100644 --- a/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py +++ b/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py @@ -38,7 +38,7 @@ class ClockRowsCursor(ScriptedCursor): """Allow malformed database-clock rows without changing the shared happy-path fake.""" def __init__(self, clock_rows: list[tuple[object, ...]]) -> None: - super().__init__([[], [(CONVERSION, RECORDED_AT)]], [[]]) + super().__init__([[], [(PERSON, RECORDED_AT)]], [[]]) self.clock_rows = list(clock_rows) def fetchmany(self, size: int) -> list[tuple[object, ...]]: @@ -52,7 +52,7 @@ class PostLockRecordedTimeRegressionTests(unittest.TestCase): """Prevent lock wait order from hiding the winner at the validation cutoff.""" def test_employment_rejects_winner_recorded_after_waiter_transaction_started(self) -> None: - """A later lock winner must be visible after the converted-person lock is acquired.""" + """A later lock winner must be visible after the Person conflict lock is acquired.""" winner_recorded_at = RECORDED_AT + timedelta(seconds=1) winner = ( UUID("0198a412-8200-7000-8000-000000000099"), @@ -66,7 +66,7 @@ def test_employment_rejects_winner_recorded_after_waiter_transaction_started(sel None, ) cursor = ScriptedCursor( - [[], [(CONVERSION, RECORDED_AT)]], + [[], [(PERSON, RECORDED_AT)]], [[winner]], clock_timestamp=winner_recorded_at, ) @@ -82,14 +82,14 @@ def test_employment_rejects_winner_recorded_after_waiter_transaction_started(sel ) sql = [statement for statement, _parameters in cursor.executions] - conversion_index = next( - index for index, statement in enumerate(sql) if "candidate_worker_conversion_record" in statement + person_lock_index = next( + index for index, statement in enumerate(sql) if "FOR UPDATE OF person" in statement ) clock_index = sql.index(_POST_LOCK_CLOCK_SQL) employment_snapshot_index = next( index for index, statement in enumerate(sql) if "JOIN public.employment_record_version" in statement ) - self.assertLess(conversion_index, clock_index) + self.assertLess(person_lock_index, clock_index) self.assertLess(clock_index, employment_snapshot_index) def test_assignment_rejects_capacity_winner_recorded_after_waiter_transaction_started(self) -> None: From ec3fb59f269e8b6f6849d3c2e9c3f4ccf53b6a73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:37:29 +0900 Subject: [PATCH 223/269] test(people): cover Person anchor temporal integrity --- ..._conversion_employment_binding_regression.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_conversion_employment_binding_regression.py b/services/people-api/tests/test_conversion_employment_binding_regression.py index 1d20094d1..84b181e43 100644 --- a/services/people-api/tests/test_conversion_employment_binding_regression.py +++ b/services/people-api/tests/test_conversion_employment_binding_regression.py @@ -1,11 +1,16 @@ -"""Regression contract for candidate-conversion versus People mutation authority.""" +"""Regression contracts for candidate-conversion versus People mutation authority.""" from __future__ import annotations import inspect import unittest -from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort +from orgmetra_people_api.mutations import PeopleMutationIntegrityError +from orgmetra_people_api.postgres_mutations import ( + PostgresPeopleMutationPort, + _require_one_person_employment_anchor, +) +from test_people_mutations import PERSON class ConversionEmploymentBindingRegressionTests(unittest.TestCase): @@ -16,6 +21,14 @@ def test_generic_employment_creation_does_not_require_candidate_conversion(self) source = inspect.getsource(PostgresPeopleMutationPort.create_employment) self.assertNotIn("_CONVERSION_SQL", source) + def test_person_anchor_rejects_non_database_time_evidence(self) -> None: + """The Person conflict anchor must reject malformed durable time evidence.""" + with self.assertRaisesRegex(PeopleMutationIntegrityError, "anchor identity"): + _require_one_person_employment_anchor( + [(PERSON, "2026-09-13T07:00:00+09:00")], + expected_person_record_id=PERSON, + ) + if __name__ == "__main__": # pragma: no cover - direct local invocation only unittest.main() From 079eb79e64a5e93c656a0a478ab6387656c76dbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:44:23 +0900 Subject: [PATCH 224/269] test(people): reject recruiting provenance as generic Assignment authority --- .../test_conversion_employment_binding_regression.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_conversion_employment_binding_regression.py b/services/people-api/tests/test_conversion_employment_binding_regression.py index 84b181e43..27d7bb3d6 100644 --- a/services/people-api/tests/test_conversion_employment_binding_regression.py +++ b/services/people-api/tests/test_conversion_employment_binding_regression.py @@ -14,13 +14,19 @@ class ConversionEmploymentBindingRegressionTests(unittest.TestCase): - """Keep recruiting provenance out of generic Employment authorization.""" + """Keep recruiting provenance out of generic Employment and Assignment authority.""" def test_generic_employment_creation_does_not_require_candidate_conversion(self) -> None: """A future Employment cannot depend on a conversion whose FK already needs it.""" source = inspect.getsource(PostgresPeopleMutationPort.create_employment) self.assertNotIn("_CONVERSION_SQL", source) + def test_generic_assignment_creation_does_not_require_candidate_conversion(self) -> None: + """Staffing an Employment cannot depend on unrelated historical recruiting provenance.""" + source = inspect.getsource(PostgresPeopleMutationPort.create_assignment) + self.assertNotIn("_CONVERSION_SQL", source) + self.assertIn("_ASSIGNMENT_EMPLOYMENT_ANCHOR_SQL", source) + def test_person_anchor_rejects_non_database_time_evidence(self) -> None: """The Person conflict anchor must reject malformed durable time evidence.""" with self.assertRaisesRegex(PeopleMutationIntegrityError, "anchor identity"): @@ -31,4 +37,4 @@ def test_person_anchor_rejects_non_database_time_evidence(self) -> None: if __name__ == "__main__": # pragma: no cover - direct local invocation only - unittest.main() + unittest.main() \ No newline at end of file From 3fa5849750c245bfaa590a1f4120adc28cdf3871 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:46:00 +0900 Subject: [PATCH 225/269] docs(people): separate recruiting provenance from generic writes --- .../src/orgmetra_people_api/mutations.py | 452 ++++++++---------- 1 file changed, 193 insertions(+), 259 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 8ffbdb1e3..8b7f2405a 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -2,9 +2,9 @@ Each command authorizes an exact resource kind before crossing the mutation port. The port owns one tenant-scoped transaction that persists the authoritative HRIS -fact together with ``record_audit_outbox_event``. Employment and assignment -writes require a current ``candidate_worker_conversion_record`` -(``recorded_to IS NULL``) and never write the legacy +fact together with ``record_audit_outbox_event``. Generic Employment and Assignment +writes operate on canonical People truth rather than treating recruiting conversion +provenance as mutation authority, and never write the legacy ``candidate_worker_link`` relation. """ @@ -78,100 +78,29 @@ def validate_idempotency_key(value: object) -> str: def _canonical_allocation_ratio(value: Decimal) -> str: - """Return the context-independent numeric(5,4) spelling used by persistence.""" - whole, _separator, fraction = format(value, "f").partition(".") - return f"{whole}.{fraction:0<4}" + """Return the exact plain-decimal assignment allocation for semantic hashing.""" + if type(value) is not Decimal or not value.is_finite(): + raise ValueError("allocation_ratio must be a finite Decimal.") + return format(value, "f") -def command_route( - command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, -) -> str: - """Return the durable route that scopes one People mutation idempotency key.""" - if type(command) is EmploymentMutationCommand: - return "employment-records" - if type(command) is PositionMutationCommand: - return "position-records" - if type(command) is AssignmentMutationCommand: - return "assignment-records" - raise TypeError("command must be a governed People mutation command") - - -def idempotency_record_id( - *, - tenant_record_id: UUID, - command_route_value: str, - idempotency_key: str, -) -> UUID: - """Derive a stable operational identity for one tenant/route/key binding.""" - _validate_operational_uuid("tenant_record_id", tenant_record_id) - return uuid5( - _IDEMPOTENCY_NAMESPACE, - f"{tenant_record_id}:{command_route_value}:{idempotency_key}", - ) - +def _canonical_uuid(value: object, *, field_name: str) -> str: + """Return a canonical UUID string after exact operational validation.""" + _validate_operational_uuid(field_name, value) + assert isinstance(value, UUID) + return str(value) -def mutation_command_digest( - *, - command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, - authorization: AuthorizationDecision, -) -> str: - """Hash method, route, tenant, actor, purpose, and semantic command fields. - Generated record identifiers are excluded so a retry that allocates fresh - UUIDs still matches the first committed command. - """ - if type(authorization) is not AuthorizationDecision: - raise TypeError("authorization must be an AuthorizationDecision") - if type(command) is EmploymentMutationCommand: - EmploymentMutationCommand.__post_init__(command) - route = "employment-records" - semantic_command: dict[str, object] = { - "confirmation_reference": command.confirmation_reference, - "effective_from": command.effective_from.isoformat(), - "employment_concurrency_code": command.employment_concurrency_code, - "employment_status_code": command.employment_status_code, - "evidence_version_code": command.evidence_version_code, - "person_record_id": str(command.person_record_id), - } - elif type(command) is PositionMutationCommand: - PositionMutationCommand.__post_init__(command) - route = "position-records" - semantic_command = { - "confirmation_reference": command.confirmation_reference, - "effective_from": command.effective_from.isoformat(), - "evidence_version_code": command.evidence_version_code, - "job_profile_id": str(command.job_profile_id), - "organization_unit_id": str(command.organization_unit_id), - "position_status_code": command.position_status_code, - } - elif type(command) is AssignmentMutationCommand: - AssignmentMutationCommand.__post_init__(command) - route = "assignment-records" - semantic_command = { - "allocation_ratio": _canonical_allocation_ratio(command.allocation_ratio), - "confirmation_reference": command.confirmation_reference, - "effective_from": command.effective_from.isoformat(), - "employment_record_id": str(command.employment_record_id), - "evidence_version_code": command.evidence_version_code, - "person_record_id": str(command.person_record_id), - "position_record_id": str(command.position_record_id), - } - else: - raise TypeError("command must be a governed People mutation command") - payload = { - "actor_reference": authorization.actor_reference, - "command_route": route, - "method": "POST", - "purpose_code": authorization.purpose_code, - "semantic_command": semantic_command, - "tenant_record_id": str(command.tenant_record_id), - } - return sha256(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")).hexdigest() +def _validate_semantic_text(field_name: str, value: object, allowed: frozenset[str]) -> str: + """Return one exact built-in governance token drawn from a bounded vocabulary.""" + if type(value) is not str or value not in allowed: + raise ValueError(f"{field_name} is invalid.") + return value @dataclass(frozen=True, slots=True) class EmploymentMutationCommand: - """Opaque identities and high-impact evidence needed to create one employment.""" + """Describe one governed Employment identity/version creation request.""" tenant_record_id: UUID person_record_id: UUID @@ -187,26 +116,19 @@ class EmploymentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed and detach UUID aliases before authorization or persistence.""" - for field_name in ( - "tenant_record_id", - "person_record_id", - "employment_record_id", - "employment_record_version_id", - "audit_event_record_id", - "outbox_delivery_record_id", - ): - identity = _validate_operational_uuid(field_name, getattr(self, field_name)) - object.__setattr__(self, field_name, UUID(int=identity)) + """Reject ambiguous or mutable command identity before authorization or persistence.""" + _validate_operational_uuid("tenant_record_id", self.tenant_record_id) + _validate_operational_uuid("person_record_id", self.person_record_id) + _validate_operational_uuid("employment_record_id", self.employment_record_id) + _validate_operational_uuid("employment_record_version_id", self.employment_record_version_id) + _validate_operational_uuid("audit_event_record_id", self.audit_event_record_id) + _validate_operational_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id) + _validate_semantic_text("employment_status_code", self.employment_status_code, _EMPLOYMENT_STATUSES) + _validate_semantic_text( + "employment_concurrency_code", self.employment_concurrency_code, _CONCURRENCY_CODES + ) if type(self.effective_from) is not date: - raise ValueError("effective_from must be a business date.") - if type(self.employment_status_code) is not str or self.employment_status_code not in _EMPLOYMENT_STATUSES: - raise ValueError("employment_status_code must be active, leave, or terminated.") - if ( - type(self.employment_concurrency_code) is not str - or self.employment_concurrency_code not in _CONCURRENCY_CODES - ): - raise ValueError("employment_concurrency_code must be exclusive or concurrent.") + raise ValueError("effective_from must be a date.") _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) validate_idempotency_key(self.idempotency_key) @@ -214,13 +136,13 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class PositionMutationCommand: - """Opaque identities and high-impact evidence needed to create one position seat.""" + """Describe one governed Position identity/version creation request.""" tenant_record_id: UUID - organization_unit_id: UUID - job_profile_id: UUID position_record_id: UUID position_record_version_id: UUID + organization_unit_id: UUID + job_profile_id: UUID audit_event_record_id: UUID outbox_delivery_record_id: UUID position_status_code: str @@ -230,22 +152,17 @@ class PositionMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed and detach UUID aliases before authorization or persistence.""" - for field_name in ( - "tenant_record_id", - "organization_unit_id", - "job_profile_id", - "position_record_id", - "position_record_version_id", - "audit_event_record_id", - "outbox_delivery_record_id", - ): - identity = _validate_operational_uuid(field_name, getattr(self, field_name)) - object.__setattr__(self, field_name, UUID(int=identity)) + """Reject ambiguous or mutable command identity before authorization or persistence.""" + _validate_operational_uuid("tenant_record_id", self.tenant_record_id) + _validate_operational_uuid("position_record_id", self.position_record_id) + _validate_operational_uuid("position_record_version_id", self.position_record_version_id) + _validate_operational_uuid("organization_unit_id", self.organization_unit_id) + _validate_operational_uuid("job_profile_id", self.job_profile_id) + _validate_operational_uuid("audit_event_record_id", self.audit_event_record_id) + _validate_operational_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id) + _validate_semantic_text("position_status_code", self.position_status_code, _POSITION_STATUSES) if type(self.effective_from) is not date: - raise ValueError("effective_from must be a business date.") - if type(self.position_status_code) is not str or self.position_status_code not in _POSITION_STATUSES: - raise ValueError("position_status_code must be a staffable or closed seat status.") + raise ValueError("effective_from must be a date.") _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) validate_idempotency_key(self.idempotency_key) @@ -253,7 +170,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class AssignmentMutationCommand: - """Opaque identities and high-impact evidence needed to create one assignment.""" + """Describe one governed Assignment creation request.""" tenant_record_id: UUID employment_record_id: UUID @@ -269,84 +186,67 @@ class AssignmentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed and detach UUID aliases before authorization or persistence.""" - for field_name in ( - "tenant_record_id", - "employment_record_id", - "person_record_id", - "position_record_id", - "assignment_record_id", - "audit_event_record_id", - "outbox_delivery_record_id", - ): - identity = _validate_operational_uuid(field_name, getattr(self, field_name)) - object.__setattr__(self, field_name, UUID(int=identity)) + """Reject ambiguous or mutable command identity before authorization or persistence.""" + _validate_operational_uuid("tenant_record_id", self.tenant_record_id) + _validate_operational_uuid("employment_record_id", self.employment_record_id) + _validate_operational_uuid("person_record_id", self.person_record_id) + _validate_operational_uuid("position_record_id", self.position_record_id) + _validate_operational_uuid("assignment_record_id", self.assignment_record_id) + _validate_operational_uuid("audit_event_record_id", self.audit_event_record_id) + _validate_operational_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id) + _canonical_allocation_ratio(self.allocation_ratio) if type(self.effective_from) is not date: - raise ValueError("effective_from must be a business date.") - if type(self.allocation_ratio) is not Decimal: - raise ValueError("allocation_ratio must be a Decimal.") - if not self.allocation_ratio.is_finite(): - raise ValueError("allocation_ratio must be finite.") - if self.allocation_ratio <= Decimal("0") or self.allocation_ratio > Decimal("1.0000"): - raise ValueError("allocation_ratio must be greater than 0 and at most 1.0000.") - if self.allocation_ratio.as_tuple().exponent < -4: - raise ValueError("allocation_ratio must have at most four decimal places.") + raise ValueError("effective_from must be a date.") _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) validate_idempotency_key(self.idempotency_key) -def _validate_replay_command_digest(value: object) -> None: - """Require exact inert replay evidence when a mutation result carries it.""" - if value is not None and type(value) is not str: - raise ValueError("replay_command_digest must be an exact string when present.") - - @dataclass(frozen=True, slots=True) class EmploymentMutationResult: - """Opaque identity and optional verified-replay evidence for one employment mutation.""" + """Return the canonical committed Employment identity and optional replay digest.""" employment_record_id: UUID replay_command_digest: str | None = None def __post_init__(self) -> None: - """Validate and detach persistence result identity from adapter-owned aliases.""" - identity = _validate_operational_uuid("employment_record_id", self.employment_record_id) - object.__setattr__(self, "employment_record_id", UUID(int=identity)) - _validate_replay_command_digest(self.replay_command_digest) + """Reject executable or sentinel result identities crossing the service boundary.""" + _validate_operational_uuid("employment_record_id", self.employment_record_id) + if self.replay_command_digest is not None and type(self.replay_command_digest) is not str: + raise ValueError("replay_command_digest must be a string when present.") @dataclass(frozen=True, slots=True) class PositionMutationResult: - """Opaque identity and optional verified-replay evidence for one position mutation.""" + """Return the canonical committed Position identity and optional replay digest.""" position_record_id: UUID replay_command_digest: str | None = None def __post_init__(self) -> None: - """Validate and detach persistence result identity from adapter-owned aliases.""" - identity = _validate_operational_uuid("position_record_id", self.position_record_id) - object.__setattr__(self, "position_record_id", UUID(int=identity)) - _validate_replay_command_digest(self.replay_command_digest) + """Reject executable or sentinel result identities crossing the service boundary.""" + _validate_operational_uuid("position_record_id", self.position_record_id) + if self.replay_command_digest is not None and type(self.replay_command_digest) is not str: + raise ValueError("replay_command_digest must be a string when present.") @dataclass(frozen=True, slots=True) class AssignmentMutationResult: - """Opaque identity and optional verified-replay evidence for one assignment mutation.""" + """Return the canonical committed Assignment identity and optional replay digest.""" assignment_record_id: UUID replay_command_digest: str | None = None def __post_init__(self) -> None: - """Validate and detach persistence result identity from adapter-owned aliases.""" - identity = _validate_operational_uuid("assignment_record_id", self.assignment_record_id) - object.__setattr__(self, "assignment_record_id", UUID(int=identity)) - _validate_replay_command_digest(self.replay_command_digest) + """Reject executable or sentinel result identities crossing the service boundary.""" + _validate_operational_uuid("assignment_record_id", self.assignment_record_id) + if self.replay_command_digest is not None and type(self.replay_command_digest) is not str: + raise ValueError("replay_command_digest must be a string when present.") @runtime_checkable class PeopleMutationPort(Protocol): - """Persist authorized People mutations atomically inside an Orgmetra-owned boundary.""" + """Persist governed People facts after application authorization.""" def create_employment( self, @@ -354,7 +254,8 @@ def create_employment( command: EmploymentMutationCommand, authorization: AuthorizationDecision, ) -> EmploymentMutationResult: - """Persist one employment or raise without partial writes.""" + """Persist one Employment mutation.""" + ... def create_position( self, @@ -362,7 +263,8 @@ def create_position( command: PositionMutationCommand, authorization: AuthorizationDecision, ) -> PositionMutationResult: - """Persist one position or raise without partial writes.""" + """Persist one Position mutation.""" + ... def create_assignment( self, @@ -370,31 +272,109 @@ def create_assignment( command: AssignmentMutationCommand, authorization: AuthorizationDecision, ) -> AssignmentMutationResult: - """Persist one assignment or raise without partial writes.""" + """Persist one Assignment mutation.""" + ... + + +def command_route( + command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, +) -> str: + """Return the exact HTTP command route used by the durable replay key.""" + if type(command) is EmploymentMutationCommand: + return "employment-records" + if type(command) is PositionMutationCommand: + return "position-records" + if type(command) is AssignmentMutationCommand: + return "assignment-records" + raise TypeError("unsupported People mutation command") -def _require_port(mutation_port: object) -> PeopleMutationPort: - """Reject objects that do not implement the People mutation port.""" - if not isinstance(mutation_port, PeopleMutationPort): - raise TypeError("mutation_port must implement PeopleMutationPort") - return mutation_port +def mutation_command_digest( + *, + command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, + authorization: AuthorizationDecision, +) -> str: + """Hash the semantic command and exact authorization context for replay binding.""" + if type(authorization) is not AuthorizationDecision: + raise TypeError("authorization must be an AuthorizationDecision") + common = { + "actor_reference": authorization.actor_reference, + "confirmation_reference": command.confirmation_reference, + "evidence_version_code": command.evidence_version_code, + "policy_version_code": authorization.policy_version_code, + "purpose_code": authorization.purpose_code, + "route": command_route(command), + "tenant_record_id": _canonical_uuid(command.tenant_record_id, field_name="tenant_record_id"), + } + if type(command) is EmploymentMutationCommand: + payload = { + **common, + "effective_from": command.effective_from.isoformat(), + "employment_concurrency_code": command.employment_concurrency_code, + "employment_status_code": command.employment_status_code, + "person_record_id": _canonical_uuid(command.person_record_id, field_name="person_record_id"), + } + elif type(command) is PositionMutationCommand: + payload = { + **common, + "effective_from": command.effective_from.isoformat(), + "job_profile_id": _canonical_uuid(command.job_profile_id, field_name="job_profile_id"), + "organization_unit_id": _canonical_uuid( + command.organization_unit_id, field_name="organization_unit_id" + ), + "position_status_code": command.position_status_code, + } + elif type(command) is AssignmentMutationCommand: + payload = { + **common, + "allocation_ratio": _canonical_allocation_ratio(command.allocation_ratio), + "effective_from": command.effective_from.isoformat(), + "employment_record_id": _canonical_uuid( + command.employment_record_id, field_name="employment_record_id" + ), + "person_record_id": _canonical_uuid(command.person_record_id, field_name="person_record_id"), + "position_record_id": _canonical_uuid( + command.position_record_id, field_name="position_record_id" + ), + } + else: # pragma: no cover - guarded by command_route and exact command construction + raise TypeError("unsupported People mutation command") + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return sha256(encoded).hexdigest() + +def idempotency_record_id(*, tenant_record_id: UUID, command_route_value: str, idempotency_key: str) -> UUID: + """Derive a stable opaque row identity from the tenant/route/key business key.""" + tenant_integer = _validate_operational_uuid("tenant_record_id", tenant_record_id) + validate_idempotency_key(idempotency_key) + if type(command_route_value) is not str or not command_route_value: + raise ValueError("command_route_value must be a non-empty string.") + return uuid5(_IDEMPOTENCY_NAMESPACE, f"{tenant_integer}:{command_route_value}:{idempotency_key}") -def _require_result_identity_or_replay( + +def _authorize_mutation( *, - result_record_id: UUID, - expected_record_id: UUID, - replay_command_digest: str | None, - expected_replay_command_digest: str, - result_name: str, -) -> None: - """Accept a foreign identity only with replay evidence bound before executable persistence.""" - if replay_command_digest is not None: - if replay_command_digest != expected_replay_command_digest: - raise PeopleMutationIntegrityError(f"{result_name} replay evidence does not match command") - return - if result_record_id != expected_record_id: - raise PeopleMutationIntegrityError(f"{result_name} result identity does not match command") + principal: AuthenticatedPrincipal, + tenant_record_id: UUID, + purpose_code: str, + resource_kind: str, + resource_id: UUID, + requested_fields: frozenset[str], + required_scope_code: str, + policy: PurposeBoundAccessPolicy, +) -> AuthorizationDecision: + """Bind one mutation to its exact tenant, actor, purpose, resource, scope, and fields.""" + return authorize_resource_fields( + principal=principal, + tenant_record_id=tenant_record_id, + purpose_code=purpose_code, + resource_kind=resource_kind, + resource_id=resource_id, + requested_fields=requested_fields, + required_scope_code=required_scope_code, + permitted_fields=requested_fields, + policy=policy, + ) def create_employment_record( @@ -405,37 +385,24 @@ def create_employment_record( policy: PurposeBoundAccessPolicy, mutation_port: PeopleMutationPort, ) -> EmploymentMutationResult: - """Authorize the exact employment target before persisting worker employment truth.""" + """Authorize and persist one governed Employment record/version.""" if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") command = replace(command) - expected_employment_record_id = UUID(int=command.employment_record_id.int) - port = _require_port(mutation_port) - authorization = authorize_resource_fields( + authorization = _authorize_mutation( principal=principal, tenant_record_id=command.tenant_record_id, - resource_tenant_record_id=command.tenant_record_id, - resource_reference=f"employment_record:{command.employment_record_id.hex}", purpose_code=purpose_code, - operation_code="create_record", resource_kind="employment_record", + resource_id=command.employment_record_id, requested_fields=_EMPLOYMENT_FIELDS, + required_scope_code="orgmetra.people.write", policy=policy, ) - expected_replay_command_digest = mutation_command_digest(command=command, authorization=authorization) - port_command = replace(command) - result = port.create_employment(command=port_command, authorization=authorization) + result = mutation_port.create_employment(command=command, authorization=authorization) if type(result) is not EmploymentMutationResult: - raise TypeError("mutation_port must return EmploymentMutationResult") - result = replace(result) - _require_result_identity_or_replay( - result_record_id=result.employment_record_id, - expected_record_id=expected_employment_record_id, - replay_command_digest=result.replay_command_digest, - expected_replay_command_digest=expected_replay_command_digest, - result_name="employment", - ) - return result + raise PeopleMutationIntegrityError("employment mutation port returned an invalid result") + return replace(result) def create_position_record( @@ -446,37 +413,24 @@ def create_position_record( policy: PurposeBoundAccessPolicy, mutation_port: PeopleMutationPort, ) -> PositionMutationResult: - """Authorize the exact position target before persisting a staffable seat.""" + """Authorize and persist one governed Position record/version.""" if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") command = replace(command) - expected_position_record_id = UUID(int=command.position_record_id.int) - port = _require_port(mutation_port) - authorization = authorize_resource_fields( + authorization = _authorize_mutation( principal=principal, tenant_record_id=command.tenant_record_id, - resource_tenant_record_id=command.tenant_record_id, - resource_reference=f"position_record:{command.position_record_id.hex}", purpose_code=purpose_code, - operation_code="create_record", resource_kind="position_record", + resource_id=command.position_record_id, requested_fields=_POSITION_FIELDS, + required_scope_code="orgmetra.job_architecture.write", policy=policy, ) - expected_replay_command_digest = mutation_command_digest(command=command, authorization=authorization) - port_command = replace(command) - result = port.create_position(command=port_command, authorization=authorization) + result = mutation_port.create_position(command=command, authorization=authorization) if type(result) is not PositionMutationResult: - raise TypeError("mutation_port must return PositionMutationResult") - result = replace(result) - _require_result_identity_or_replay( - result_record_id=result.position_record_id, - expected_record_id=expected_position_record_id, - replay_command_digest=result.replay_command_digest, - expected_replay_command_digest=expected_replay_command_digest, - result_name="position", - ) - return result + raise PeopleMutationIntegrityError("position mutation port returned an invalid result") + return replace(result) def create_assignment_record( @@ -487,41 +441,21 @@ def create_assignment_record( policy: PurposeBoundAccessPolicy, mutation_port: PeopleMutationPort, ) -> AssignmentMutationResult: - """Authorize the exact assignment target before persisting seat allocation.""" + """Authorize and persist one governed Assignment record.""" if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") command = replace(command) - expected_assignment_record_id = UUID(int=command.assignment_record_id.int) - port = _require_port(mutation_port) - authorization = authorize_resource_fields( + authorization = _authorize_mutation( principal=principal, tenant_record_id=command.tenant_record_id, - resource_tenant_record_id=command.tenant_record_id, - resource_reference=f"assignment_record:{command.assignment_record_id.hex}", purpose_code=purpose_code, - operation_code="create_record", resource_kind="assignment_record", + resource_id=command.assignment_record_id, requested_fields=_ASSIGNMENT_FIELDS, + required_scope_code="orgmetra.people.write", policy=policy, ) - expected_replay_command_digest = mutation_command_digest(command=command, authorization=authorization) - port_command = replace(command) - result = port.create_assignment(command=port_command, authorization=authorization) + result = mutation_port.create_assignment(command=command, authorization=authorization) if type(result) is not AssignmentMutationResult: - raise TypeError("mutation_port must return AssignmentMutationResult") - result = replace(result) - _require_result_identity_or_replay( - result_record_id=result.assignment_record_id, - expected_record_id=expected_assignment_record_id, - replay_command_digest=result.replay_command_digest, - expected_replay_command_digest=expected_replay_command_digest, - result_name="assignment", - ) - return result - - -def parse_allocation_ratio(raw_value: object) -> Decimal: - """Parse the OpenAPI allocation token into an exact four-decimal ratio.""" - if type(raw_value) is not str or re.fullmatch(r"^(0\.(?!0000)[0-9]{4}|1\.0000)$", raw_value) is None: - raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") - return Decimal(raw_value) \ No newline at end of file + raise PeopleMutationIntegrityError("assignment mutation port returned an invalid result") + return replace(result) From c7a0d15299c9d71af1fef0547e9b727ad8d7e420 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:47:27 +0900 Subject: [PATCH 226/269] fix(people): serialize generic Assignment on Employment root --- .../orgmetra_people_api/postgres_mutations.py | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/postgres_mutations.py b/services/people-api/src/orgmetra_people_api/postgres_mutations.py index 1ae25597e..7f21f5444 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -1,10 +1,11 @@ """Atomic PostgreSQL adapter for governed People employment, position, and assignment writes. The adapter writes only Orgmetra-owned canonical HRIS relations. Generic Employment -creation serializes on its current Person aggregate; Assignment still requires a -current ``candidate_worker_conversion_record`` and never inserts -``candidate_worker_link``. Every accepted write calls ``record_audit_outbox_event`` -in the same tenant-bound transaction. +creation serializes on its current Person aggregate; generic Assignment creation +serializes on its named Employment and Position aggregates rather than recruiting +provenance. Neither path writes the legacy ``candidate_worker_link`` relation. +Every accepted write calls ``record_audit_outbox_event`` in the same tenant-bound +transaction. """ from __future__ import annotations @@ -67,16 +68,15 @@ FOR UPDATE OF person """.strip() -_CONVERSION_SQL = """ +_ASSIGNMENT_EMPLOYMENT_ANCHOR_SQL = """ SELECT - conversion.candidate_worker_conversion_record_id, - pg_catalog.transaction_timestamp() -FROM public.candidate_worker_conversion_record AS conversion -WHERE conversion.tenant_record_id = %s - AND conversion.person_record_id = %s - AND conversion.recorded_to IS NULL + employment.employment_record_id, + employment.person_record_id +FROM public.employment_record AS employment +WHERE employment.tenant_record_id = %s + AND employment.employment_record_id = %s LIMIT 2 -FOR UPDATE OF conversion +FOR UPDATE OF employment """.strip() _EMPLOYMENT_VERSIONS_SQL = """ @@ -551,23 +551,30 @@ def _require_one_person_employment_anchor( raise PeopleMutationIntegrityError("person employment anchor identity is invalid") -def _require_one_conversion(rows: object) -> tuple[UUID, datetime]: - """Require exactly one current conversion row and a usable transaction timestamp.""" +def _require_one_assignment_employment_anchor( + rows: object, + *, + expected_employment_record_id: UUID, + expected_person_record_id: UUID, +) -> None: + """Require the exact Employment root that serializes Assignment eligibility and capacity.""" detached = _unpack_fixed_rows( rows, row_width=2, - error_message="conversion row has an invalid shape", + error_message="assignment employment anchor row has an invalid shape", ) if not detached: - raise PeopleMutationIntegrityError("person has no governed candidate-worker conversion") + raise PeopleMutationNotFound("assignment employment record was not found") if len(detached) != 1: - raise PeopleMutationIntegrityError("multiple candidate-worker conversions matched the person") - conversion_id, recorded_at = detached[0] - if not _is_operational_uuid(conversion_id) or not _is_aware_datetime(recorded_at): - raise PeopleMutationIntegrityError("conversion identity or transaction time is invalid") - assert isinstance(conversion_id, UUID) - assert isinstance(recorded_at, datetime) - return conversion_id, recorded_at + raise PeopleMutationIntegrityError("multiple assignment employment anchors matched the record") + employment_record_id, person_record_id = detached[0] + if ( + not _is_operational_uuid(employment_record_id) + or not _is_operational_uuid(person_record_id) + or employment_record_id != expected_employment_record_id + or person_record_id != expected_person_record_id + ): + raise PeopleMutationIntegrityError("assignment employment anchor identity is invalid") def _post_lock_recorded_at(cursor: Any) -> datetime: @@ -836,7 +843,7 @@ def create_assignment( command: AssignmentMutationCommand, authorization: AuthorizationDecision, ) -> AssignmentMutationResult: - """Persist one assignment after conversion and kernel coverage checks.""" + """Persist one Assignment after Employment/Position serialization and kernel checks.""" if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") command = replace(command) @@ -859,8 +866,15 @@ def create_assignment( assignment_record_id=replayed_record_id, replay_command_digest=replay_digest, ) - cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) - _require_one_conversion(cursor.fetchmany(2)) + cursor.execute( + _ASSIGNMENT_EMPLOYMENT_ANCHOR_SQL, + (command.tenant_record_id, command.employment_record_id), + ) + _require_one_assignment_employment_anchor( + cursor.fetchmany(2), + expected_employment_record_id=command.employment_record_id, + expected_person_record_id=command.person_record_id, + ) cursor.execute( _NAMED_EMPLOYMENT_VERSIONS_SQL, (command.tenant_record_id, command.employment_record_id), From 3918d2a3b4cd267c9b7bdbd9e3c6b700910d2c58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:47:38 +0900 Subject: [PATCH 227/269] test(people): bind Assignment concurrency to Employment root --- ..._postgres_mutation_concurrency_contract.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/services/people-api/tests/test_postgres_mutation_concurrency_contract.py b/services/people-api/tests/test_postgres_mutation_concurrency_contract.py index aef6c628d..c38805dd3 100644 --- a/services/people-api/tests/test_postgres_mutation_concurrency_contract.py +++ b/services/people-api/tests/test_postgres_mutation_concurrency_contract.py @@ -1,21 +1,29 @@ """Concurrency contracts for governed PostgreSQL People mutations. -These tests pin the database-locking boundary that prevents two distinct -idempotency keys from validating the same stale employment or position -snapshot and then committing mutually incompatible authoritative facts. +These tests pin the database-locking boundaries that prevent distinct idempotency +keys from validating stale Employment or Position snapshots and then committing +mutually incompatible authoritative facts. """ from orgmetra_people_api import postgres_mutations -def test_employment_conflict_snapshot_is_serialized_per_converted_person() -> None: - """Lock the current conversion before reading person employment history.""" +def test_employment_conflict_snapshot_is_serialized_per_person() -> None: + """Lock the current Person before reading its Employment portfolio.""" assert "ISOLATION LEVEL READ COMMITTED" in postgres_mutations._READ_WRITE_SQL - assert "FOR UPDATE OF conversion" in postgres_mutations._CONVERSION_SQL + assert "FROM public.person_record AS person" in postgres_mutations._PERSON_EMPLOYMENT_ANCHOR_SQL + assert "FOR UPDATE OF person" in postgres_mutations._PERSON_EMPLOYMENT_ANCHOR_SQL + + +def test_assignment_employment_snapshot_is_serialized_on_employment_root() -> None: + """Lock the named Employment before reading eligibility and allocation state.""" + sql = postgres_mutations._ASSIGNMENT_EMPLOYMENT_ANCHOR_SQL + assert "FROM public.employment_record AS employment" in sql + assert "FOR UPDATE OF employment" in sql def test_assignment_capacity_snapshot_is_serialized_per_position() -> None: - """Lock the position before reading the assignments used for capacity validation.""" + """Lock the Position before reading the assignments used for seat capacity validation.""" sql = postgres_mutations._NAMED_POSITION_VERSIONS_SQL assert "FROM public.position_record AS position" in sql assert "JOIN public.position_record_version AS version" in sql From 63f0d94e7590ba3c2ac9adbe626fad5b9da86b80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:47:57 +0900 Subject: [PATCH 228/269] test(people): order Assignment Employment and Position locks --- ...st_postgres_mutation_recorded_time_regression.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py b/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py index 6243f23cf..692f7a178 100644 --- a/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py +++ b/services/people-api/tests/test_postgres_mutation_recorded_time_regression.py @@ -15,7 +15,6 @@ from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort from test_postgres_people_mutations import ( ASSIGNMENT, - CONVERSION, EMPLOYMENT, PERSON, POSITION, @@ -93,7 +92,7 @@ def test_employment_rejects_winner_recorded_after_waiter_transaction_started(sel self.assertLess(clock_index, employment_snapshot_index) def test_assignment_rejects_capacity_winner_recorded_after_waiter_transaction_started(self) -> None: - """A later lock winner must be visible after the position lock is acquired.""" + """A later lock winner must be visible after Employment then Position locks are acquired.""" winner_recorded_at = RECORDED_AT + timedelta(seconds=1) winner = ( UUID("0198a412-8200-7000-8000-000000000071"), @@ -107,7 +106,7 @@ def test_assignment_rejects_capacity_winner_recorded_after_waiter_transaction_st None, ) cursor = ScriptedCursor( - [[], [(CONVERSION, RECORDED_AT)]], + [[], [(EMPLOYMENT, PERSON)]], [[covering_employment_row()], [covering_position_row()], [winner]], clock_timestamp=winner_recorded_at, ) @@ -123,6 +122,12 @@ def test_assignment_rejects_capacity_winner_recorded_after_waiter_transaction_st ) sql = [statement for statement, _parameters in cursor.executions] + employment_lock_index = next( + index for index, statement in enumerate(sql) if "FOR UPDATE OF employment" in statement + ) + employment_snapshot_index = next( + index for index, statement in enumerate(sql) if "JOIN public.employment_record_version" in statement + ) position_lock_index = next( index for index, statement in enumerate(sql) if "FOR UPDATE OF position" in statement ) @@ -130,6 +135,8 @@ def test_assignment_rejects_capacity_winner_recorded_after_waiter_transaction_st assignment_snapshot_index = next( index for index, statement in enumerate(sql) if statement.startswith("SELECT\n assignment.assignment_record_id") ) + self.assertLess(employment_lock_index, employment_snapshot_index) + self.assertLess(employment_snapshot_index, position_lock_index) self.assertLess(position_lock_index, clock_index) self.assertLess(clock_index, assignment_snapshot_index) From acff8870c12a3be6258b9b579e40b93292fdb078 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:49:00 +0900 Subject: [PATCH 229/269] test(people): cover canonical Assignment conflict anchors --- .../tests/test_postgres_people_mutations.py | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index 051332eee..2793e6161 100644 --- a/services/people-api/tests/test_postgres_people_mutations.py +++ b/services/people-api/tests/test_postgres_people_mutations.py @@ -255,7 +255,7 @@ def test_position_requires_parents_and_records_audit(self) -> None: self.assertIn("public.people_mutation_idempotency_record", sql_text) self.assertNotIn("candidate_worker_link", sql_text) - def test_assignment_reuses_kernel_and_conversion_then_audits(self) -> None: + def test_assignment_serializes_on_employment_then_position_and_audits(self) -> None: prior_assignment = ( UUID("0198a412-8200-7000-8000-000000000071"), EMPLOYMENT, @@ -268,7 +268,7 @@ def test_assignment_reuses_kernel_and_conversion_then_audits(self) -> None: None, ) port, cursor = self._port( - [[(CONVERSION, RECORDED_AT)]], + [[(EMPLOYMENT, PERSON)]], [[covering_employment_row()], [covering_position_row()], [prior_assignment]], ) result = create_assignment_record( @@ -280,11 +280,11 @@ def test_assignment_reuses_kernel_and_conversion_then_audits(self) -> None: ) self.assertEqual(result.assignment_record_id, ASSIGNMENT) sql_text = "\n".join(sql for sql, _parameters in cursor.executions) - self.assertIn("public.candidate_worker_conversion_record", sql_text) - conversion_sql = next( - sql for sql, _parameters in cursor.executions if "candidate_worker_conversion_record" in sql + self.assertNotIn("public.candidate_worker_conversion_record", sql_text) + employment_lock_sql = next( + sql for sql, _parameters in cursor.executions if "FOR UPDATE OF employment" in sql ) - self.assertIn("conversion.recorded_to IS NULL", conversion_sql) + self.assertIn("public.employment_record", employment_lock_sql) self.assertIn("public.assignment_record", sql_text) self.assertIn("public.record_audit_outbox_event", sql_text) self.assertIn("public.people_mutation_idempotency_record", sql_text) @@ -311,17 +311,26 @@ def test_employment_missing_or_invalid_person_anchor_fails_before_insert(self) - ) self.assertFalse(any("INSERT INTO public.employment_record" in sql for sql, _parameters in cursor.executions)) - def test_assignment_missing_conversion_fails_before_insert(self) -> None: - port, cursor = self._port([[]], [[], [], []]) - with self.assertRaisesRegex(PeopleMutationIntegrityError, "candidate-worker conversion"): - create_assignment_record( - principal=PRINCIPAL, - command=assignment_command(), - purpose_code="workforce_admin", - policy=assignment_policy(), - mutation_port=port, - ) - self.assertFalse(any("INSERT INTO public.assignment_record" in sql for sql, _parameters in cursor.executions)) + def test_assignment_missing_or_invalid_employment_anchor_fails_before_insert(self) -> None: + scenarios = ( + [[]], + [[(EMPLOYMENT, PERSON), (EMPLOYMENT, PERSON)]], + [[(EMPLOYMENT,)]], + [[(UUID(int=0), PERSON)]], + [[(EMPLOYMENT, POSITION)]], + ) + for rows in scenarios: + with self.subTest(rows=rows): + port, cursor = self._port(rows) + with self.assertRaises((PeopleMutationIntegrityError, PeopleMutationNotFound)): + create_assignment_record( + principal=PRINCIPAL, + command=assignment_command(), + purpose_code="workforce_admin", + policy=assignment_policy(), + mutation_port=port, + ) + self.assertFalse(any("INSERT INTO public.assignment_record" in sql for sql, _parameters in cursor.executions)) def test_invalid_existing_employment_row_fails_closed(self) -> None: port, _cursor = self._port([[(PERSON, RECORDED_AT)]], [[("bad",)]]) @@ -387,7 +396,7 @@ def test_ambiguous_or_mismatched_position_parents_fail_closed(self) -> None: ) def test_assignment_kernel_rejection_and_invalid_rows_fail_closed(self) -> None: - port, cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[], [], []]) + port, cursor = self._port([[(EMPLOYMENT, PERSON)]], [[], [], []]) with self.assertRaises(PeopleMutationIntegrityError): create_assignment_record( principal=PRINCIPAL, @@ -398,7 +407,7 @@ def test_assignment_kernel_rejection_and_invalid_rows_fail_closed(self) -> None: ) self.assertFalse(any("INSERT INTO public.assignment_record" in sql for sql, _parameters in cursor.executions)) - port, _cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[("bad",)], [], []]) + port, _cursor = self._port([[(EMPLOYMENT, PERSON)]], [[("bad",)], [], []]) with self.assertRaises(PeopleMutationIntegrityError): create_assignment_record( principal=PRINCIPAL, @@ -409,7 +418,7 @@ def test_assignment_kernel_rejection_and_invalid_rows_fail_closed(self) -> None: ) port, _cursor = self._port( - [[(CONVERSION, RECORDED_AT)]], + [[(EMPLOYMENT, PERSON)]], [[covering_employment_row()], [("bad",)], []], ) with self.assertRaises(PeopleMutationIntegrityError): @@ -422,7 +431,7 @@ def test_assignment_kernel_rejection_and_invalid_rows_fail_closed(self) -> None: ) port, _cursor = self._port( - [[(CONVERSION, RECORDED_AT)]], + [[(EMPLOYMENT, PERSON)]], [[covering_employment_row()], [covering_position_row()], [("bad",)]], ) with self.assertRaises(PeopleMutationIntegrityError): @@ -494,7 +503,7 @@ def test_invalid_version_cell_values_fail_closed(self) -> None: ) bad_position = (POSITION, POSITION_VERSION, "open", "2026-08-01", None, RECORDED_AT, None) port, _cursor = self._port( - [[(CONVERSION, RECORDED_AT)]], + [[(EMPLOYMENT, PERSON)]], [[covering_employment_row()], [bad_position], []], ) with self.assertRaisesRegex(PeopleMutationIntegrityError, "invalid"): @@ -517,7 +526,7 @@ def test_invalid_version_cell_values_fail_closed(self) -> None: None, ) port, _cursor = self._port( - [[(CONVERSION, RECORDED_AT)]], + [[(EMPLOYMENT, PERSON)]], [[covering_employment_row()], [covering_position_row()], [bad_assignment]], ) with self.assertRaisesRegex(PeopleMutationIntegrityError, "invalid"): From aae2a7f453218befbdfd853d5905df00b3d97db3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:49:52 +0900 Subject: [PATCH 230/269] test(people): prove Assignment without recruiting conversion --- ...ssignment_without_conversion_acceptance.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py diff --git a/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py b/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py new file mode 100644 index 000000000..903b57873 --- /dev/null +++ b/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py @@ -0,0 +1,102 @@ +"""Real PostgreSQL acceptance for staffing an Employment without recruiting provenance.""" + +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path +import runpy +from uuid import UUID + +from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort + + +def _load_acceptance_namespace() -> dict[str, object]: + """Reuse the reviewed isolated-PostgreSQL/libpq harness without duplicating transport code.""" + source = Path(__file__).with_name("test_postgres_assignment_concurrency_acceptance.py") + return runpy.run_path(str(source)) + + +def test_generic_assignment_does_not_require_candidate_worker_conversion() -> None: + """Canonical Employment/Position truth must be sufficient for a governed Assignment.""" + acceptance = _load_acceptance_namespace() + isolated_postgres = acceptance["_isolated_postgres"] + psql = acceptance["_psql"] + tenant = acceptance["_TENANT"] + person = acceptance["_PERSON_ONE"] + employment = acceptance["_EMPLOYMENT_ONE"] + position = acceptance["_POSITION_ONE"] + organization = UUID("10000000-0000-7000-8004-000000000001") + job = UUID("10000000-0000-7000-8004-000000000002") + + with isolated_postgres() as database_url: + psql( + database_url, + f""" + INSERT INTO tenant_record (tenant_record_id, tenant_reference) + VALUES ('{tenant}', 'assignment_without_conversion'); + INSERT INTO person_record (tenant_record_id, person_record_id, recorded_from) + VALUES ('{tenant}', '{person}', TIMESTAMPTZ '2026-09-13 00:00:00+00'); + INSERT INTO employment_record + (tenant_record_id, employment_record_id, person_record_id, recorded_from) + VALUES ('{tenant}', '{employment}', '{person}', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + INSERT INTO employment_record_version + (tenant_record_id, employment_record_version_id, employment_record_id, + employment_status_code, employment_concurrency_code, effective_from, recorded_from) + VALUES ('{tenant}', '10000000-0000-7000-8004-000000000003', '{employment}', + 'active', 'exclusive', DATE '2026-09-01', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + INSERT INTO organization_unit (tenant_record_id, organization_unit_id, recorded_from) + VALUES ('{tenant}', '{organization}', TIMESTAMPTZ '2026-09-13 00:00:00+00'); + INSERT INTO job_profile (tenant_record_id, job_profile_id, recorded_from) + VALUES ('{tenant}', '{job}', TIMESTAMPTZ '2026-09-13 00:00:00+00'); + INSERT INTO position_record + (tenant_record_id, position_record_id, organization_unit_id, job_profile_id, recorded_from) + VALUES ('{tenant}', '{position}', '{organization}', '{job}', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + INSERT INTO position_record_version + (tenant_record_id, position_record_version_id, position_record_id, + position_status_code, effective_from, recorded_from) + VALUES ('{tenant}', '10000000-0000-7000-8004-000000000004', '{position}', + 'open', DATE '2026-09-01', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + """, + ) + assert psql( + database_url, + f"SELECT count(*) FROM candidate_worker_conversion_record WHERE tenant_record_id = '{tenant}'::uuid;", + ) == "0" + + assignment_id = UUID("10000000-0000-7000-8004-000000000005") + command = acceptance["_assignment_command"]( + assignment_id=assignment_id, + employment_id=employment, + person_id=person, + position_id=position, + allocation=Decimal("0.5000"), + suffix=21, + ) + factory = acceptance["_ConnectionFactory"]( + database_url, + application_name="orgmetra-assignment-without-conversion", + ) + result = PostgresPeopleMutationPort(factory).create_assignment( + command=command, + authorization=acceptance["_authorization"](assignment_id), + ) + assert result.assignment_record_id == assignment_id + assert all(connection.closed for connection in factory.connections) + + state = psql( + database_url, + f""" + SELECT concat_ws('|', + (SELECT count(*) FROM assignment_record + WHERE tenant_record_id = '{tenant}'::uuid + AND assignment_record_id = '{assignment_id}'::uuid), + (SELECT count(*) FROM candidate_worker_conversion_record + WHERE tenant_record_id = '{tenant}'::uuid), + (SELECT count(*) FROM audit_event_record + WHERE canonical_event_json::jsonb ->> 'type' = 'orgmetra.people.assignment_created'), + (SELECT count(*) FROM people_mutation_idempotency_record + WHERE tenant_record_id = '{tenant}'::uuid + AND command_route = 'assignment-records')); + """, + ) + assert state == "1|0|1|1" From 252f41801e6be907b0ae29cc43dffa2934ee0d0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:50:15 +0900 Subject: [PATCH 231/269] docs(adr): align People conflict roots with canonical aggregates --- .../0005-exclusive-employment-and-staffable-seats.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/adr/0005-exclusive-employment-and-staffable-seats.md b/docs/adr/0005-exclusive-employment-and-staffable-seats.md index d6c4deca4..5f9f32bef 100644 --- a/docs/adr/0005-exclusive-employment-and-staffable-seats.md +++ b/docs/adr/0005-exclusive-employment-and-staffable-seats.md @@ -13,21 +13,29 @@ ADR 0004 bound assignments to a named employment and split employment/position i - prevent two people from consuming more than 1.0000 of one seat; - stop an assignment after a seat was closed, frozen, or abolished. +A later implementation also exposed an authority/concurrency ambiguity: `candidate_worker_conversion_record` is recruiting-origin provenance bound to a particular hire decision, Person, and resulting Employment. Treating a current conversion row as the generic prerequisite or serialization root for later Employment/Assignment writes makes historical recruiting provenance an accidental authorization mechanism and fails for legitimate People-admin Employments that did not originate from that recruiting conversion. + Allen (1983) treats interval overlap as a first-class relation. Diez-Roux (1998) and Robinson (1950) warn that treating nested assignments as independent atoms hides unit-level over-allocation. ISO 30414:2025 requires reconstructable workforce counts. Jensen and Snodgrass (1999) require a knowledge cutoff so a later freeze cannot rewrite what was known earlier. ## Decision - `employment_record_version.employment_concurrency_code` is `exclusive` or `concurrent`. Exclusive periods for one person cannot overlap. +- Generic Employment creation serializes on the tenant-qualified current `person_record`, then re-reads that Person's Employment portfolio in a fresh READ COMMITTED statement before applying exclusivity checks. +- Generic Assignment creation serializes first on the named tenant-qualified `employment_record`, then on the named `position_record`, and evaluates Employment eligibility plus Employment/Position allocation from snapshots taken after the relevant conflict locks. Employment separation uses the same Employment aggregate conflict root. +- `candidate_worker_conversion_record` remains governed recruiting/hire provenance and analytic lineage. It is not generic Employment or Assignment mutation authority and is not the concurrency root for staffing an already authoritative Employment. - `orgmetra_hris_kernel` 0.4.0 rejects assignments that are not covered by an `active` or `open` position version. - Visible allocations for one `position_record_id` cannot exceed 1.0000 on a reconstructed day. - `POST /v1/employment-records`, `POST /v1/position-records`, and `POST /v1/assignment-records` reuse the same Keyverse mutation context, human confirmation, and versioned evidence composition as other high-impact commands. ## Consequences -- HR can hire, open a seat, and assign a worker through the contract instead of only through kernel fixtures. +- HR can create an Employment through an authorized People-admin path and later staff it without fabricating or reusing recruiting conversion evidence. +- Recruiting-origin conversion continues to prove the hire lineage for recruiting, validity-study, and related evidence flows without becoming a generic People write gate. +- Different idempotency keys cannot validate the same stale Employment allocation/eligibility portfolio merely because they do not share a recruiting conversion key; the Employment root is the stable conflict boundary. +- Assignment/separation races are coordinated on the same Employment aggregate, while seat-capacity races remain coordinated on Position. - A second job must be marked concurrent, or the prior exclusive period must end, before save. - Closing or freezing a seat fails later assignment days even when employment coverage remains valid. -- Persistence still applies these kernel checks before insert; this ADR does not add HTTP handlers. +- Persistence still applies these kernel checks before insert; this ADR does not make recruiting evidence optional where a recruiting-specific contract explicitly requires it. ## References From 854ae873fecc0455f41973ee037451cd1ca2da3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 07:58:25 +0900 Subject: [PATCH 232/269] fix(foundation): reseal People conflict-root ADR --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 71ffe3ca3..bee80325f 100644 --- a/manifest.json +++ b/manifest.json @@ -275,9 +275,9 @@ }, { "path": "docs/adr/0005-exclusive-employment-and-staffable-seats.md", - "sha256": "10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b", - "bytes": 2091, - "lines": 34 + "sha256": "a575f8bde53b2e7ce88f64607d96b0005c4d67f780a9bc95b612770b13e8ce09", + "bytes": 4053, + "lines": 42 }, { "path": "docs/adr/0006-governed-audit-outbox-envelope.md", From 31ce6a6762cd7fa821adf418251f72b413206546 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 08:10:15 +0900 Subject: [PATCH 233/269] fix(people): restore governed mutation boundary integrity --- .../src/orgmetra_people_api/mutations.py | 388 +++++++++++++++--- 1 file changed, 323 insertions(+), 65 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 8b7f2405a..e68c61b70 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -10,7 +10,7 @@ from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass from datetime import date from decimal import Decimal from hashlib import sha256 @@ -30,6 +30,7 @@ _IDEMPOTENCY_NAMESPACE = UUID("0198a412-9000-7000-8000-0000000000aa") _REFERENCE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$") _VERSION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") +_ALLOCATION_PATTERN = re.compile(r"^(0\.[0-9]{4}|1\.0000)$") _EMPLOYMENT_STATUSES = frozenset({"active", "leave", "terminated"}) _CONCURRENCY_CODES = frozenset({"exclusive", "concurrent"}) _POSITION_STATUSES = frozenset({"active", "open", "closed", "frozen", "abolished"}) @@ -56,6 +57,18 @@ def _validate_operational_uuid(field_name: str, value: object) -> int: return identity +def _clone_uuid(field_name: str, value: object) -> UUID: + """Detach one exact operational UUID from caller- or port-retained aliases.""" + return UUID(int=_validate_operational_uuid(field_name, value)) + + +def _clone_date(field_name: str, value: object) -> date: + """Detach one exact business date after runtime revalidation.""" + if type(value) is not date: + raise ValueError(f"{field_name} must be a date.") + return date(value.year, value.month, value.day) + + def _validate_confirmation(value: object) -> None: """Require one namespaced human-confirmation reference.""" if type(value) is not str or _REFERENCE_PATTERN.fullmatch(value) is None: @@ -77,18 +90,29 @@ def validate_idempotency_key(value: object) -> str: return value +def _validate_allocation_ratio(value: object) -> Decimal: + """Require one finite strictly-positive Assignment ratio with at most four decimals.""" + if type(value) is not Decimal: + raise ValueError("allocation_ratio must be a Decimal.") + if not value.is_finite(): + raise ValueError("allocation_ratio must be finite.") + if value <= Decimal("0") or value > Decimal("1.0000"): + raise ValueError("allocation_ratio must be greater than 0 and at most 1.0000.") + if value.as_tuple().exponent < -4: + raise ValueError("allocation_ratio must have at most four decimal places.") + return value + + def _canonical_allocation_ratio(value: Decimal) -> str: - """Return the exact plain-decimal assignment allocation for semantic hashing.""" - if type(value) is not Decimal or not value.is_finite(): - raise ValueError("allocation_ratio must be a finite Decimal.") - return format(value, "f") + """Return the context-independent numeric(5,4) spelling used by persistence.""" + ratio = _validate_allocation_ratio(value) + whole, _separator, fraction = format(ratio, "f").partition(".") + return f"{whole}.{fraction:0<4}" def _canonical_uuid(value: object, *, field_name: str) -> str: """Return a canonical UUID string after exact operational validation.""" - _validate_operational_uuid(field_name, value) - assert isinstance(value, UUID) - return str(value) + return str(UUID(int=_validate_operational_uuid(field_name, value))) def _validate_semantic_text(field_name: str, value: object, allowed: frozenset[str]) -> str: @@ -194,7 +218,7 @@ def __post_init__(self) -> None: _validate_operational_uuid("assignment_record_id", self.assignment_record_id) _validate_operational_uuid("audit_event_record_id", self.audit_event_record_id) _validate_operational_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id) - _canonical_allocation_ratio(self.allocation_ratio) + _validate_allocation_ratio(self.allocation_ratio) if type(self.effective_from) is not date: raise ValueError("effective_from must be a date.") _validate_confirmation(self.confirmation_reference) @@ -276,6 +300,91 @@ def create_assignment( ... +def _snapshot_employment_command(command: EmploymentMutationCommand) -> EmploymentMutationCommand: + """Detach and revalidate all Employment command authority before callbacks run.""" + return EmploymentMutationCommand( + tenant_record_id=_clone_uuid("tenant_record_id", command.tenant_record_id), + person_record_id=_clone_uuid("person_record_id", command.person_record_id), + employment_record_id=_clone_uuid("employment_record_id", command.employment_record_id), + employment_record_version_id=_clone_uuid( + "employment_record_version_id", command.employment_record_version_id + ), + audit_event_record_id=_clone_uuid("audit_event_record_id", command.audit_event_record_id), + outbox_delivery_record_id=_clone_uuid( + "outbox_delivery_record_id", command.outbox_delivery_record_id + ), + employment_status_code=command.employment_status_code, + employment_concurrency_code=command.employment_concurrency_code, + effective_from=_clone_date("effective_from", command.effective_from), + confirmation_reference=command.confirmation_reference, + evidence_version_code=command.evidence_version_code, + idempotency_key=command.idempotency_key, + ) + + +def _snapshot_position_command(command: PositionMutationCommand) -> PositionMutationCommand: + """Detach and revalidate all Position command authority before callbacks run.""" + return PositionMutationCommand( + tenant_record_id=_clone_uuid("tenant_record_id", command.tenant_record_id), + position_record_id=_clone_uuid("position_record_id", command.position_record_id), + position_record_version_id=_clone_uuid( + "position_record_version_id", command.position_record_version_id + ), + organization_unit_id=_clone_uuid("organization_unit_id", command.organization_unit_id), + job_profile_id=_clone_uuid("job_profile_id", command.job_profile_id), + audit_event_record_id=_clone_uuid("audit_event_record_id", command.audit_event_record_id), + outbox_delivery_record_id=_clone_uuid( + "outbox_delivery_record_id", command.outbox_delivery_record_id + ), + position_status_code=command.position_status_code, + effective_from=_clone_date("effective_from", command.effective_from), + confirmation_reference=command.confirmation_reference, + evidence_version_code=command.evidence_version_code, + idempotency_key=command.idempotency_key, + ) + + +def _snapshot_assignment_command(command: AssignmentMutationCommand) -> AssignmentMutationCommand: + """Detach and revalidate all Assignment command authority before callbacks run.""" + ratio = _validate_allocation_ratio(command.allocation_ratio) + return AssignmentMutationCommand( + tenant_record_id=_clone_uuid("tenant_record_id", command.tenant_record_id), + employment_record_id=_clone_uuid("employment_record_id", command.employment_record_id), + person_record_id=_clone_uuid("person_record_id", command.person_record_id), + position_record_id=_clone_uuid("position_record_id", command.position_record_id), + assignment_record_id=_clone_uuid("assignment_record_id", command.assignment_record_id), + audit_event_record_id=_clone_uuid("audit_event_record_id", command.audit_event_record_id), + outbox_delivery_record_id=_clone_uuid( + "outbox_delivery_record_id", command.outbox_delivery_record_id + ), + allocation_ratio=Decimal(ratio.as_tuple()), + effective_from=_clone_date("effective_from", command.effective_from), + confirmation_reference=command.confirmation_reference, + evidence_version_code=command.evidence_version_code, + idempotency_key=command.idempotency_key, + ) + + +def _snapshot_authorization(authorization: AuthorizationDecision) -> AuthorizationDecision: + """Detach authorization evidence before a persistence adapter can retain or rewrite it.""" + if type(authorization) is not AuthorizationDecision: + raise TypeError("authorization must be an AuthorizationDecision") + return AuthorizationDecision( + allowed=authorization.allowed, + tenant_record_id=_clone_uuid("authorization.tenant_record_id", authorization.tenant_record_id), + actor_reference=authorization.actor_reference, + resource_reference=authorization.resource_reference, + policy_version_code=authorization.policy_version_code, + purpose_code=authorization.purpose_code, + operation_code=authorization.operation_code, + resource_kind=authorization.resource_kind, + requested_fields=frozenset(authorization.requested_fields), + authorized_fields=frozenset(authorization.authorized_fields), + reason_code=authorization.reason_code, + next_action=authorization.next_action, + ) + + def command_route( command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, ) -> str: @@ -286,7 +395,7 @@ def command_route( return "position-records" if type(command) is AssignmentMutationCommand: return "assignment-records" - raise TypeError("unsupported People mutation command") + raise TypeError("command must be a governed People mutation command") def mutation_command_digest( @@ -294,62 +403,92 @@ def mutation_command_digest( command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, authorization: AuthorizationDecision, ) -> str: - """Hash the semantic command and exact authorization context for replay binding.""" + """Hash one revalidated semantic command and exact authorization context for replay binding.""" if type(authorization) is not AuthorizationDecision: raise TypeError("authorization must be an AuthorizationDecision") + route = command_route(command) + if type(command) is EmploymentMutationCommand: + semantic_command = _snapshot_employment_command(command) + elif type(command) is PositionMutationCommand: + semantic_command = _snapshot_position_command(command) + else: + semantic_command = _snapshot_assignment_command(command) common = { "actor_reference": authorization.actor_reference, - "confirmation_reference": command.confirmation_reference, - "evidence_version_code": command.evidence_version_code, + "confirmation_reference": semantic_command.confirmation_reference, + "evidence_version_code": semantic_command.evidence_version_code, "policy_version_code": authorization.policy_version_code, "purpose_code": authorization.purpose_code, - "route": command_route(command), - "tenant_record_id": _canonical_uuid(command.tenant_record_id, field_name="tenant_record_id"), + "route": route, + "tenant_record_id": _canonical_uuid( + semantic_command.tenant_record_id, field_name="tenant_record_id" + ), } - if type(command) is EmploymentMutationCommand: + if type(semantic_command) is EmploymentMutationCommand: payload = { **common, - "effective_from": command.effective_from.isoformat(), - "employment_concurrency_code": command.employment_concurrency_code, - "employment_status_code": command.employment_status_code, - "person_record_id": _canonical_uuid(command.person_record_id, field_name="person_record_id"), + "effective_from": semantic_command.effective_from.isoformat(), + "employment_concurrency_code": semantic_command.employment_concurrency_code, + "employment_status_code": semantic_command.employment_status_code, + "person_record_id": _canonical_uuid( + semantic_command.person_record_id, field_name="person_record_id" + ), } - elif type(command) is PositionMutationCommand: + elif type(semantic_command) is PositionMutationCommand: payload = { **common, - "effective_from": command.effective_from.isoformat(), - "job_profile_id": _canonical_uuid(command.job_profile_id, field_name="job_profile_id"), + "effective_from": semantic_command.effective_from.isoformat(), + "job_profile_id": _canonical_uuid( + semantic_command.job_profile_id, field_name="job_profile_id" + ), "organization_unit_id": _canonical_uuid( - command.organization_unit_id, field_name="organization_unit_id" + semantic_command.organization_unit_id, field_name="organization_unit_id" ), - "position_status_code": command.position_status_code, + "position_status_code": semantic_command.position_status_code, } - elif type(command) is AssignmentMutationCommand: + else: payload = { **common, - "allocation_ratio": _canonical_allocation_ratio(command.allocation_ratio), - "effective_from": command.effective_from.isoformat(), + "allocation_ratio": _canonical_allocation_ratio(semantic_command.allocation_ratio), + "effective_from": semantic_command.effective_from.isoformat(), "employment_record_id": _canonical_uuid( - command.employment_record_id, field_name="employment_record_id" + semantic_command.employment_record_id, field_name="employment_record_id" + ), + "person_record_id": _canonical_uuid( + semantic_command.person_record_id, field_name="person_record_id" ), - "person_record_id": _canonical_uuid(command.person_record_id, field_name="person_record_id"), "position_record_id": _canonical_uuid( - command.position_record_id, field_name="position_record_id" + semantic_command.position_record_id, field_name="position_record_id" ), } - else: # pragma: no cover - guarded by command_route and exact command construction - raise TypeError("unsupported People mutation command") - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") return sha256(encoded).hexdigest() -def idempotency_record_id(*, tenant_record_id: UUID, command_route_value: str, idempotency_key: str) -> UUID: +def idempotency_record_id( + *, + tenant_record_id: UUID, + command_route_value: str, + idempotency_key: str, +) -> UUID: """Derive a stable opaque row identity from the tenant/route/key business key.""" tenant_integer = _validate_operational_uuid("tenant_record_id", tenant_record_id) validate_idempotency_key(idempotency_key) if type(command_route_value) is not str or not command_route_value: raise ValueError("command_route_value must be a non-empty string.") - return uuid5(_IDEMPOTENCY_NAMESPACE, f"{tenant_integer}:{command_route_value}:{idempotency_key}") + return uuid5( + _IDEMPOTENCY_NAMESPACE, + f"{tenant_integer}:{command_route_value}:{idempotency_key}", + ) + + +def _require_port(mutation_port: object) -> PeopleMutationPort: + """Reject objects that do not implement the People mutation port.""" + if not isinstance(mutation_port, PeopleMutationPort): + raise TypeError("mutation_port must implement PeopleMutationPort") + return mutation_port def _authorize_mutation( @@ -360,23 +499,80 @@ def _authorize_mutation( resource_kind: str, resource_id: UUID, requested_fields: frozenset[str], - required_scope_code: str, policy: PurposeBoundAccessPolicy, ) -> AuthorizationDecision: - """Bind one mutation to its exact tenant, actor, purpose, resource, scope, and fields.""" + """Delegate exact mutation authorization without duplicating Keyverse policy ownership.""" return authorize_resource_fields( principal=principal, tenant_record_id=tenant_record_id, + resource_tenant_record_id=tenant_record_id, + resource_reference=f"{resource_kind}:{resource_id.hex}", purpose_code=purpose_code, + operation_code="create_record", resource_kind=resource_kind, - resource_id=resource_id, requested_fields=requested_fields, - required_scope_code=required_scope_code, - permitted_fields=requested_fields, policy=policy, ) +def _snapshot_employment_result(result: object) -> EmploymentMutationResult: + """Detach and revalidate one Employment persistence receipt.""" + if type(result) is not EmploymentMutationResult: + raise TypeError("mutation_port must return EmploymentMutationResult") + replay_digest = result.replay_command_digest + if replay_digest is not None and type(replay_digest) is not str: + raise ValueError("replay_command_digest must be a string when present.") + return EmploymentMutationResult( + employment_record_id=_clone_uuid("employment_record_id", result.employment_record_id), + replay_command_digest=replay_digest, + ) + + +def _snapshot_position_result(result: object) -> PositionMutationResult: + """Detach and revalidate one Position persistence receipt.""" + if type(result) is not PositionMutationResult: + raise TypeError("mutation_port must return PositionMutationResult") + replay_digest = result.replay_command_digest + if replay_digest is not None and type(replay_digest) is not str: + raise ValueError("replay_command_digest must be a string when present.") + return PositionMutationResult( + position_record_id=_clone_uuid("position_record_id", result.position_record_id), + replay_command_digest=replay_digest, + ) + + +def _snapshot_assignment_result(result: object) -> AssignmentMutationResult: + """Detach and revalidate one Assignment persistence receipt.""" + if type(result) is not AssignmentMutationResult: + raise TypeError("mutation_port must return AssignmentMutationResult") + replay_digest = result.replay_command_digest + if replay_digest is not None and type(replay_digest) is not str: + raise ValueError("replay_command_digest must be a string when present.") + return AssignmentMutationResult( + assignment_record_id=_clone_uuid("assignment_record_id", result.assignment_record_id), + replay_command_digest=replay_digest, + ) + + +def _require_result_integrity( + *, + expected_identity: UUID, + actual_identity: UUID, + replay_command_digest: str | None, + expected_command_digest: str, + result_name: str, +) -> None: + """Accept the commanded target or a first-commit replay proven by the semantic digest.""" + if replay_command_digest is not None and replay_command_digest != expected_command_digest: + raise PeopleMutationIntegrityError( + f"{result_name} replay evidence does not match command" + ) + if actual_identity.int != expected_identity.int and replay_command_digest is None: + raise PeopleMutationIntegrityError( + f"{result_name} result identity does not match command" + ) + + def create_employment_record( *, principal: AuthenticatedPrincipal, @@ -388,21 +584,39 @@ def create_employment_record( """Authorize and persist one governed Employment record/version.""" if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") - command = replace(command) + port = _require_port(mutation_port) + semantic_command = _snapshot_employment_command(command) + expected_identity = _clone_uuid( + "employment_record_id", semantic_command.employment_record_id + ) authorization = _authorize_mutation( principal=principal, - tenant_record_id=command.tenant_record_id, + tenant_record_id=semantic_command.tenant_record_id, purpose_code=purpose_code, resource_kind="employment_record", - resource_id=command.employment_record_id, + resource_id=semantic_command.employment_record_id, requested_fields=_EMPLOYMENT_FIELDS, - required_scope_code="orgmetra.people.write", policy=policy, ) - result = mutation_port.create_employment(command=command, authorization=authorization) - if type(result) is not EmploymentMutationResult: - raise PeopleMutationIntegrityError("employment mutation port returned an invalid result") - return replace(result) + authorization_for_port = _snapshot_authorization(authorization) + expected_digest = mutation_command_digest( + command=semantic_command, + authorization=authorization_for_port, + ) + result = _snapshot_employment_result( + port.create_employment( + command=semantic_command, + authorization=authorization_for_port, + ) + ) + _require_result_integrity( + expected_identity=expected_identity, + actual_identity=result.employment_record_id, + replay_command_digest=result.replay_command_digest, + expected_command_digest=expected_digest, + result_name="employment", + ) + return result def create_position_record( @@ -416,21 +630,37 @@ def create_position_record( """Authorize and persist one governed Position record/version.""" if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") - command = replace(command) + port = _require_port(mutation_port) + semantic_command = _snapshot_position_command(command) + expected_identity = _clone_uuid("position_record_id", semantic_command.position_record_id) authorization = _authorize_mutation( principal=principal, - tenant_record_id=command.tenant_record_id, + tenant_record_id=semantic_command.tenant_record_id, purpose_code=purpose_code, resource_kind="position_record", - resource_id=command.position_record_id, + resource_id=semantic_command.position_record_id, requested_fields=_POSITION_FIELDS, - required_scope_code="orgmetra.job_architecture.write", policy=policy, ) - result = mutation_port.create_position(command=command, authorization=authorization) - if type(result) is not PositionMutationResult: - raise PeopleMutationIntegrityError("position mutation port returned an invalid result") - return replace(result) + authorization_for_port = _snapshot_authorization(authorization) + expected_digest = mutation_command_digest( + command=semantic_command, + authorization=authorization_for_port, + ) + result = _snapshot_position_result( + port.create_position( + command=semantic_command, + authorization=authorization_for_port, + ) + ) + _require_result_integrity( + expected_identity=expected_identity, + actual_identity=result.position_record_id, + replay_command_digest=result.replay_command_digest, + expected_command_digest=expected_digest, + result_name="position", + ) + return result def create_assignment_record( @@ -444,18 +674,46 @@ def create_assignment_record( """Authorize and persist one governed Assignment record.""" if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") - command = replace(command) + port = _require_port(mutation_port) + semantic_command = _snapshot_assignment_command(command) + expected_identity = _clone_uuid( + "assignment_record_id", semantic_command.assignment_record_id + ) authorization = _authorize_mutation( principal=principal, - tenant_record_id=command.tenant_record_id, + tenant_record_id=semantic_command.tenant_record_id, purpose_code=purpose_code, resource_kind="assignment_record", - resource_id=command.assignment_record_id, + resource_id=semantic_command.assignment_record_id, requested_fields=_ASSIGNMENT_FIELDS, - required_scope_code="orgmetra.people.write", policy=policy, ) - result = mutation_port.create_assignment(command=command, authorization=authorization) - if type(result) is not AssignmentMutationResult: - raise PeopleMutationIntegrityError("assignment mutation port returned an invalid result") - return replace(result) + authorization_for_port = _snapshot_authorization(authorization) + expected_digest = mutation_command_digest( + command=semantic_command, + authorization=authorization_for_port, + ) + result = _snapshot_assignment_result( + port.create_assignment( + command=semantic_command, + authorization=authorization_for_port, + ) + ) + _require_result_integrity( + expected_identity=expected_identity, + actual_identity=result.assignment_record_id, + replay_command_digest=result.replay_command_digest, + expected_command_digest=expected_digest, + result_name="assignment", + ) + return result + + +def parse_allocation_ratio(raw_value: object) -> Decimal: + """Parse the OpenAPI allocation token into an exact strictly-positive four-decimal ratio.""" + if type(raw_value) is not str or _ALLOCATION_PATTERN.fullmatch(raw_value) is None: + raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") + ratio = Decimal(raw_value) + if ratio <= Decimal("0"): + raise ValueError("allocation_ratio must match 0.0001-1.0000 four-decimal form.") + return ratio From 842a28bea02de9a98877637c4989f8376e9e5e96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 08:16:31 +0900 Subject: [PATCH 234/269] test(people): make Assignment provenance fixture time-safe --- ...res_assignment_without_conversion_acceptance.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py b/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py index 903b57873..75774e91b 100644 --- a/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py @@ -35,27 +35,27 @@ def test_generic_assignment_does_not_require_candidate_worker_conversion() -> No INSERT INTO tenant_record (tenant_record_id, tenant_reference) VALUES ('{tenant}', 'assignment_without_conversion'); INSERT INTO person_record (tenant_record_id, person_record_id, recorded_from) - VALUES ('{tenant}', '{person}', TIMESTAMPTZ '2026-09-13 00:00:00+00'); + VALUES ('{tenant}', '{person}', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO employment_record (tenant_record_id, employment_record_id, person_record_id, recorded_from) - VALUES ('{tenant}', '{employment}', '{person}', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + VALUES ('{tenant}', '{employment}', '{person}', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO employment_record_version (tenant_record_id, employment_record_version_id, employment_record_id, employment_status_code, employment_concurrency_code, effective_from, recorded_from) VALUES ('{tenant}', '10000000-0000-7000-8004-000000000003', '{employment}', - 'active', 'exclusive', DATE '2026-09-01', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + 'active', 'exclusive', DATE '2026-09-01', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO organization_unit (tenant_record_id, organization_unit_id, recorded_from) - VALUES ('{tenant}', '{organization}', TIMESTAMPTZ '2026-09-13 00:00:00+00'); + VALUES ('{tenant}', '{organization}', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO job_profile (tenant_record_id, job_profile_id, recorded_from) - VALUES ('{tenant}', '{job}', TIMESTAMPTZ '2026-09-13 00:00:00+00'); + VALUES ('{tenant}', '{job}', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO position_record (tenant_record_id, position_record_id, organization_unit_id, job_profile_id, recorded_from) - VALUES ('{tenant}', '{position}', '{organization}', '{job}', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + VALUES ('{tenant}', '{position}', '{organization}', '{job}', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO position_record_version (tenant_record_id, position_record_version_id, position_record_id, position_status_code, effective_from, recorded_from) VALUES ('{tenant}', '10000000-0000-7000-8004-000000000004', '{position}', - 'open', DATE '2026-09-01', TIMESTAMPTZ '2026-09-13 00:01:00+00'); + 'open', DATE '2026-09-01', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); """, ) assert psql( From 15fa5cad0c0d40ca694e2ac56287e13a0e27ffd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 08:17:28 +0900 Subject: [PATCH 235/269] fix(people): detach mutation identities at construction --- .../src/orgmetra_people_api/mutations.py | 112 ++++++++++++------ 1 file changed, 76 insertions(+), 36 deletions(-) diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index e68c61b70..9628f6c94 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -140,19 +140,30 @@ class EmploymentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Reject ambiguous or mutable command identity before authorization or persistence.""" - _validate_operational_uuid("tenant_record_id", self.tenant_record_id) - _validate_operational_uuid("person_record_id", self.person_record_id) - _validate_operational_uuid("employment_record_id", self.employment_record_id) - _validate_operational_uuid("employment_record_version_id", self.employment_record_version_id) - _validate_operational_uuid("audit_event_record_id", self.audit_event_record_id) - _validate_operational_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id) + """Reject ambiguous input and retain a detached immutable command snapshot.""" + object.__setattr__(self, "tenant_record_id", _clone_uuid("tenant_record_id", self.tenant_record_id)) + object.__setattr__(self, "person_record_id", _clone_uuid("person_record_id", self.person_record_id)) + object.__setattr__( + self, "employment_record_id", _clone_uuid("employment_record_id", self.employment_record_id) + ) + object.__setattr__( + self, + "employment_record_version_id", + _clone_uuid("employment_record_version_id", self.employment_record_version_id), + ) + object.__setattr__( + self, "audit_event_record_id", _clone_uuid("audit_event_record_id", self.audit_event_record_id) + ) + object.__setattr__( + self, + "outbox_delivery_record_id", + _clone_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id), + ) _validate_semantic_text("employment_status_code", self.employment_status_code, _EMPLOYMENT_STATUSES) _validate_semantic_text( "employment_concurrency_code", self.employment_concurrency_code, _CONCURRENCY_CODES ) - if type(self.effective_from) is not date: - raise ValueError("effective_from must be a date.") + object.__setattr__(self, "effective_from", _clone_date("effective_from", self.effective_from)) _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) validate_idempotency_key(self.idempotency_key) @@ -176,17 +187,30 @@ class PositionMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Reject ambiguous or mutable command identity before authorization or persistence.""" - _validate_operational_uuid("tenant_record_id", self.tenant_record_id) - _validate_operational_uuid("position_record_id", self.position_record_id) - _validate_operational_uuid("position_record_version_id", self.position_record_version_id) - _validate_operational_uuid("organization_unit_id", self.organization_unit_id) - _validate_operational_uuid("job_profile_id", self.job_profile_id) - _validate_operational_uuid("audit_event_record_id", self.audit_event_record_id) - _validate_operational_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id) + """Reject ambiguous input and retain a detached immutable command snapshot.""" + object.__setattr__(self, "tenant_record_id", _clone_uuid("tenant_record_id", self.tenant_record_id)) + object.__setattr__( + self, "position_record_id", _clone_uuid("position_record_id", self.position_record_id) + ) + object.__setattr__( + self, + "position_record_version_id", + _clone_uuid("position_record_version_id", self.position_record_version_id), + ) + object.__setattr__( + self, "organization_unit_id", _clone_uuid("organization_unit_id", self.organization_unit_id) + ) + object.__setattr__(self, "job_profile_id", _clone_uuid("job_profile_id", self.job_profile_id)) + object.__setattr__( + self, "audit_event_record_id", _clone_uuid("audit_event_record_id", self.audit_event_record_id) + ) + object.__setattr__( + self, + "outbox_delivery_record_id", + _clone_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id), + ) _validate_semantic_text("position_status_code", self.position_status_code, _POSITION_STATUSES) - if type(self.effective_from) is not date: - raise ValueError("effective_from must be a date.") + object.__setattr__(self, "effective_from", _clone_date("effective_from", self.effective_from)) _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) validate_idempotency_key(self.idempotency_key) @@ -210,17 +234,27 @@ class AssignmentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Reject ambiguous or mutable command identity before authorization or persistence.""" - _validate_operational_uuid("tenant_record_id", self.tenant_record_id) - _validate_operational_uuid("employment_record_id", self.employment_record_id) - _validate_operational_uuid("person_record_id", self.person_record_id) - _validate_operational_uuid("position_record_id", self.position_record_id) - _validate_operational_uuid("assignment_record_id", self.assignment_record_id) - _validate_operational_uuid("audit_event_record_id", self.audit_event_record_id) - _validate_operational_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id) - _validate_allocation_ratio(self.allocation_ratio) - if type(self.effective_from) is not date: - raise ValueError("effective_from must be a date.") + """Reject ambiguous input and retain a detached immutable command snapshot.""" + object.__setattr__(self, "tenant_record_id", _clone_uuid("tenant_record_id", self.tenant_record_id)) + object.__setattr__( + self, "employment_record_id", _clone_uuid("employment_record_id", self.employment_record_id) + ) + object.__setattr__(self, "person_record_id", _clone_uuid("person_record_id", self.person_record_id)) + object.__setattr__(self, "position_record_id", _clone_uuid("position_record_id", self.position_record_id)) + object.__setattr__( + self, "assignment_record_id", _clone_uuid("assignment_record_id", self.assignment_record_id) + ) + object.__setattr__( + self, "audit_event_record_id", _clone_uuid("audit_event_record_id", self.audit_event_record_id) + ) + object.__setattr__( + self, + "outbox_delivery_record_id", + _clone_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id), + ) + ratio = _validate_allocation_ratio(self.allocation_ratio) + object.__setattr__(self, "allocation_ratio", Decimal(ratio.as_tuple())) + object.__setattr__(self, "effective_from", _clone_date("effective_from", self.effective_from)) _validate_confirmation(self.confirmation_reference) _validate_evidence_version(self.evidence_version_code) validate_idempotency_key(self.idempotency_key) @@ -234,8 +268,10 @@ class EmploymentMutationResult: replay_command_digest: str | None = None def __post_init__(self) -> None: - """Reject executable or sentinel result identities crossing the service boundary.""" - _validate_operational_uuid("employment_record_id", self.employment_record_id) + """Detach and validate durable Employment receipt identity.""" + object.__setattr__( + self, "employment_record_id", _clone_uuid("employment_record_id", self.employment_record_id) + ) if self.replay_command_digest is not None and type(self.replay_command_digest) is not str: raise ValueError("replay_command_digest must be a string when present.") @@ -248,8 +284,10 @@ class PositionMutationResult: replay_command_digest: str | None = None def __post_init__(self) -> None: - """Reject executable or sentinel result identities crossing the service boundary.""" - _validate_operational_uuid("position_record_id", self.position_record_id) + """Detach and validate durable Position receipt identity.""" + object.__setattr__( + self, "position_record_id", _clone_uuid("position_record_id", self.position_record_id) + ) if self.replay_command_digest is not None and type(self.replay_command_digest) is not str: raise ValueError("replay_command_digest must be a string when present.") @@ -262,8 +300,10 @@ class AssignmentMutationResult: replay_command_digest: str | None = None def __post_init__(self) -> None: - """Reject executable or sentinel result identities crossing the service boundary.""" - _validate_operational_uuid("assignment_record_id", self.assignment_record_id) + """Detach and validate durable Assignment receipt identity.""" + object.__setattr__( + self, "assignment_record_id", _clone_uuid("assignment_record_id", self.assignment_record_id) + ) if self.replay_command_digest is not None and type(self.replay_command_digest) is not str: raise ValueError("replay_command_digest must be a string when present.") From 02e412b8da816ba8e657ad0478aeda0d5e3e0d67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 08:32:18 +0900 Subject: [PATCH 236/269] test(people): align generic Assignment fixture coverage window --- .../test_postgres_assignment_without_conversion_acceptance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py b/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py index 75774e91b..a40dae5b6 100644 --- a/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py +++ b/services/people-api/tests/test_postgres_assignment_without_conversion_acceptance.py @@ -43,7 +43,7 @@ def test_generic_assignment_does_not_require_candidate_worker_conversion() -> No (tenant_record_id, employment_record_version_id, employment_record_id, employment_status_code, employment_concurrency_code, effective_from, recorded_from) VALUES ('{tenant}', '10000000-0000-7000-8004-000000000003', '{employment}', - 'active', 'exclusive', DATE '2026-09-01', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); + 'active', 'exclusive', DATE '2026-08-17', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO organization_unit (tenant_record_id, organization_unit_id, recorded_from) VALUES ('{tenant}', '{organization}', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); INSERT INTO job_profile (tenant_record_id, job_profile_id, recorded_from) @@ -55,7 +55,7 @@ def test_generic_assignment_does_not_require_candidate_worker_conversion() -> No (tenant_record_id, position_record_version_id, position_record_id, position_status_code, effective_from, recorded_from) VALUES ('{tenant}', '10000000-0000-7000-8004-000000000004', '{position}', - 'open', DATE '2026-09-01', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); + 'open', DATE '2026-08-17', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); """, ) assert psql( From af87c0fd21ea5b5443cc552817863f1881843e91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 08:32:33 +0900 Subject: [PATCH 237/269] test(people): cover detached snapshot fail-closed guards --- ...ple_mutation_snapshot_runtime_integrity.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 services/people-api/tests/test_people_mutation_snapshot_runtime_integrity.py diff --git a/services/people-api/tests/test_people_mutation_snapshot_runtime_integrity.py b/services/people-api/tests/test_people_mutation_snapshot_runtime_integrity.py new file mode 100644 index 000000000..2ee270a7a --- /dev/null +++ b/services/people-api/tests/test_people_mutation_snapshot_runtime_integrity.py @@ -0,0 +1,60 @@ +"""Runtime-integrity regressions for detached People mutation snapshots.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_people_api import mutations + +TENANT = UUID("0198a412-b500-7000-8000-000000000001") +EMPLOYMENT = UUID("0198a412-b500-7000-8000-000000000030") +POSITION = UUID("0198a412-b500-7000-8000-000000000040") +ASSIGNMENT = UUID("0198a412-b500-7000-8000-000000000070") + + +def test_position_and_assignment_receipts_reject_non_string_replay_digest() -> None: + """Receipt constructors must reject executable or otherwise non-string replay evidence.""" + with pytest.raises(ValueError, match="replay_command_digest must be a string"): + mutations.PositionMutationResult(position_record_id=POSITION, replay_command_digest=object()) + with pytest.raises(ValueError, match="replay_command_digest must be a string"): + mutations.AssignmentMutationResult(assignment_record_id=ASSIGNMENT, replay_command_digest=object()) + + +def test_authorization_snapshot_rejects_noncanonical_runtime_type() -> None: + """Persistence authorization snapshots must accept only the canonical decision runtime type.""" + with pytest.raises(TypeError, match="authorization must be an AuthorizationDecision"): + mutations._snapshot_authorization(object()) + + +def test_idempotency_identity_rejects_empty_command_route() -> None: + """An empty route must never collapse otherwise valid tenant/idempotency identities.""" + with pytest.raises(ValueError, match="command_route_value must be a non-empty string"): + mutations.idempotency_record_id( + tenant_record_id=TENANT, + command_route_value="", + idempotency_key="snapshot-runtime-integrity-1", + ) + + +def test_result_snapshots_reject_post_construction_replay_digest_rewrite() -> None: + """A port must not rewrite replay evidence after a valid receipt has been constructed.""" + cases = ( + ( + mutations._snapshot_employment_result, + mutations.EmploymentMutationResult(employment_record_id=EMPLOYMENT), + ), + ( + mutations._snapshot_position_result, + mutations.PositionMutationResult(position_record_id=POSITION), + ), + ( + mutations._snapshot_assignment_result, + mutations.AssignmentMutationResult(assignment_record_id=ASSIGNMENT), + ), + ) + for snapshot, result in cases: + object.__setattr__(result, "replay_command_digest", object()) + with pytest.raises(ValueError, match="replay_command_digest must be a string"): + snapshot(result) From 9ed69b6e80d5a4dbc5486baa0fc334a0c17bed50 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:04:04 +0900 Subject: [PATCH 238/269] test(people): require governed separation reason vocabulary --- ...employment_separation_reason_vocabulary.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 services/people-api/tests/test_employment_separation_reason_vocabulary.py diff --git a/services/people-api/tests/test_employment_separation_reason_vocabulary.py b/services/people-api/tests/test_employment_separation_reason_vocabulary.py new file mode 100644 index 000000000..20cd8b40e --- /dev/null +++ b/services/people-api/tests/test_employment_separation_reason_vocabulary.py @@ -0,0 +1,59 @@ +"""Governed reason-code contract for authoritative Employment separation.""" + +from __future__ import annotations + +from datetime import date +import unittest +from uuid import UUID + +from orgmetra_people_api.separation import EmploymentSeparationCommand + +TENANT = UUID("0198a412-8000-7000-8000-000000000001") +PERSON = UUID("0198a412-8000-7000-8000-000000000020") +EMPLOYMENT = UUID("0198a412-8000-7000-8000-000000000030") +EXPECTED_VERSION = UUID("0198a412-8000-7000-8000-000000000031") +AUDIT_EVENT = UUID("0198a412-8000-7000-8000-000000000080") +OUTBOX = UUID("0198a412-8000-7000-8000-000000000081") + +REVIEWED_REASON_CODES = ( + "voluntary_resignation", + "retirement_transition", + "fixed_term_completion", + "position_elimination", + "employer_initiated_separation", +) + + +def command(reason_code: str) -> EmploymentSeparationCommand: + """Build one otherwise-valid separation command for reason-code validation.""" + return EmploymentSeparationCommand( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=EMPLOYMENT, + expected_employment_record_version_id=EXPECTED_VERSION, + separation_effective_on=date(2026, 10, 1), + separation_reason_code=reason_code, + evidence_reference="separation_packet:case-17", + evidence_version_code="v1", + confirmation_reference="human_confirmation:case-17", + idempotency_key="employment-separation-case-17", + audit_event_record_id=AUDIT_EVENT, + outbox_delivery_record_id=OUTBOX, + ) + + +class EmploymentSeparationReasonVocabularyTests(unittest.TestCase): + """Keep mutation reasons value-free and inside the reviewed People vocabulary.""" + + def test_accepts_each_reviewed_reason_code(self) -> None: + for reason_code in REVIEWED_REASON_CODES: + with self.subTest(reason_code=reason_code): + self.assertEqual(command(reason_code).separation_reason_code, reason_code) + + def test_rejects_unreviewed_lower_snake_case_reason(self) -> None: + with self.assertRaisesRegex(ValueError, "approved separation reason"): + command("manager_notes_compensation_case") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 279da6523f467e3c8068b34f84a420df22c8a10d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:04:46 +0900 Subject: [PATCH 239/269] fix(people): close separation reason vocabulary --- .../people-api/src/orgmetra_people_api/separation.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/services/people-api/src/orgmetra_people_api/separation.py b/services/people-api/src/orgmetra_people_api/separation.py index 919f3fc2c..b20250a37 100644 --- a/services/people-api/src/orgmetra_people_api/separation.py +++ b/services/people-api/src/orgmetra_people_api/separation.py @@ -19,6 +19,15 @@ _REFERENCE_PATTERN = re.compile(r"^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$") _VERSION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") _REASON_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") +_ALLOWED_SEPARATION_REASON_CODES = frozenset( + { + "voluntary_resignation", + "retirement_transition", + "fixed_term_completion", + "position_elimination", + "employer_initiated_separation", + } +) _EMPLOYMENT_FIELDS = frozenset({"employment_record"}) @@ -102,6 +111,8 @@ def __post_init__(self) -> None: raise ValueError("separation_reason_code must be a lower snake_case code.") if _REASON_PATTERN.fullmatch(self.separation_reason_code) is None: raise ValueError("separation_reason_code must be a lower snake_case code.") + if self.separation_reason_code not in _ALLOWED_SEPARATION_REASON_CODES: + raise ValueError("separation_reason_code must be an approved separation reason.") _namespaced_reference("evidence_reference", self.evidence_reference) _version_code(self.evidence_version_code) _namespaced_reference("confirmation_reference", self.confirmation_reference) From 4458afa01ea60f46713e41215b8c042516a400f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:06:07 +0900 Subject: [PATCH 240/269] fix(people): enforce separation reason vocabulary in database --- .../0014_employment_separation_transition.sql | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/database/migrations/0014_employment_separation_transition.sql b/database/migrations/0014_employment_separation_transition.sql index c0e0c0555..00ac02f2f 100644 --- a/database/migrations/0014_employment_separation_transition.sql +++ b/database/migrations/0014_employment_separation_transition.sql @@ -90,7 +90,15 @@ CREATE TABLE public.employment_separation_record ( CONSTRAINT employment_separation_status_check CHECK (separation_status_code = 'terminated'), CONSTRAINT employment_separation_reason_check - CHECK (separation_reason_code ~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$'), + CHECK ( + separation_reason_code IN ( + 'voluntary_resignation', + 'retirement_transition', + 'fixed_term_completion', + 'position_elimination', + 'employer_initiated_separation' + ) + ), CONSTRAINT employment_separation_evidence_reference_check CHECK (evidence_reference ~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$'), CONSTRAINT employment_separation_evidence_version_check @@ -205,7 +213,13 @@ BEGIN USING ERRCODE = '22023'; END IF; IF p_separation_reason_code IS NULL - OR p_separation_reason_code !~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' THEN + OR p_separation_reason_code NOT IN ( + 'voluntary_resignation', + 'retirement_transition', + 'fixed_term_completion', + 'position_elimination', + 'employer_initiated_separation' + ) THEN RAISE EXCEPTION 'employment separation reason code is invalid' USING ERRCODE = '22023'; END IF; From 118c4971b38efd33527009c2e06b2e425eb06ab1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:08:39 +0900 Subject: [PATCH 241/269] build: reseal separation reason migration --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index bee80325f..66641cae8 100644 --- a/manifest.json +++ b/manifest.json @@ -137,9 +137,9 @@ }, { "path": "database/migrations/0014_employment_separation_transition.sql", - "sha256": "e24ffddb03fae5d9a2be859656860b58054eb3453a156eec3b402509797dfdc2", - "bytes": 21465, - "lines": 523 + "sha256": "8b7ce61220b55b260e432c26a66cdc16a744e8625778ca75095e7ba76bdabd43", + "bytes": 21853, + "lines": 537 }, { "path": "database/migrations/0015_employment_separation_capability_hardening.sql", From c804ca83bc198535a33e928e9ad2b29c66fcca15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:10:36 +0900 Subject: [PATCH 242/269] test(people): reject unreviewed separation reasons in postgres --- tests/test_employment_separation_postgres.sh | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_employment_separation_postgres.sh b/tests/test_employment_separation_postgres.sh index cc1f8dc8e..fccbdaef3 100644 --- a/tests/test_employment_separation_postgres.sh +++ b/tests/test_employment_separation_postgres.sh @@ -394,6 +394,31 @@ if [[ ${assignment_status} -eq 0 || "${assignment_output}" != *"assignment coord exit 1 fi +set +e +unreviewed_reason_output="$({ tenant_psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT * FROM public.separate_employment_record_once( + '${TENANT_ID}'::uuid, + '${PERSON_ID}'::uuid, + '00000000-0000-7000-8000-000000000102'::uuid, + '00000000-0000-7000-8000-000000000202'::uuid, + DATE '2026-08-01', + 'manager_notes_compensation_case', + 'separation_packet:sep-unreviewed-reason', + 'v1', + 'keyverse_subject:operator-17', + 'workforce_admin', + 'human_confirmation:separation-unreviewed-reason', + 'employment-separation-key-unreviewed-reason', + '00000000-0000-4000-8000-000000000510'::uuid, + '00000000-0000-4000-8000-000000000610'::uuid +);"; } 2>&1)" +unreviewed_reason_status=$? +set -e +if [[ ${unreviewed_reason_status} -eq 0 || "${unreviewed_reason_output}" != *"reason code is invalid"* ]]; then + echo "unreviewed separation reason was not rejected before mutation: ${unreviewed_reason_output}" >&2 + exit 1 +fi + first_concurrent_output="$(mktemp)" second_concurrent_output="$(mktemp)" cleanup() { From fa191e5d624d4ecf0618acfb2f54947d56e23e3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:12:00 +0900 Subject: [PATCH 243/269] build: reseal separation reason contract --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 66641cae8..bc4b88326 100644 --- a/manifest.json +++ b/manifest.json @@ -455,9 +455,9 @@ }, { "path": "tests/test_employment_separation_postgres.sh", - "sha256": "703a7080f241ee3e4961d391c38365b5e4080b561d5f53dbedf4ffffb182dbca", - "bytes": 20835, - "lines": 536 + "sha256": "b70680e69aa8115435601ffa664860723a44fe68cdccf8d3d23197205abc4fce", + "bytes": 21800, + "lines": 561 }, { "path": "tests/test_evidence_sealing_postgres.sh", From d000c07886779b604297284d4ed19acd540581c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:14:27 +0900 Subject: [PATCH 244/269] test(people): close public separation reason vocabulary --- ...st_employment_separation_openapi_contract.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/services/people-api/tests/test_employment_separation_openapi_contract.py b/services/people-api/tests/test_employment_separation_openapi_contract.py index 095081ea4..74de6a7f2 100644 --- a/services/people-api/tests/test_employment_separation_openapi_contract.py +++ b/services/people-api/tests/test_employment_separation_openapi_contract.py @@ -7,6 +7,13 @@ _REPOSITORY_ROOT = Path(__file__).resolve().parents[3] _OPENAPI_PATH = _REPOSITORY_ROOT / "schemas" / "openapi.yaml" +_APPROVED_SEPARATION_REASONS = ( + "voluntary_resignation", + "retirement_transition", + "fixed_term_completion", + "position_elimination", + "employer_initiated_separation", +) def _yaml_block(document: str, marker: str) -> str: @@ -70,7 +77,15 @@ def test_request_schema_matches_application_command(self) -> None: self.assertIn(f" - {field_name}", block) self.assertIn(f" {field_name}:", block) self.assertIn(" additionalProperties: false", block) - self.assertIn("pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$'", block) + reason_block = _yaml_block(block, " separation_reason_code:") + self.assertIn(" enum:", reason_block) + published_reasons = tuple( + line.strip()[2:] + for line in reason_block.splitlines() + if line.strip().startswith("- ") + ) + self.assertEqual(published_reasons, _APPROVED_SEPARATION_REASONS) + self.assertNotIn("pattern:", reason_block) self.assertIn("pattern: '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$'", block) self.assertIn("pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$'", block) From 101af52939bcb897701cd753063d12eccc994c45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:15:24 +0900 Subject: [PATCH 245/269] fix(people): publish controlled separation reasons --- schemas/openapi.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 14ada0b6e..f27d04603 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -639,7 +639,12 @@ components: format: date separation_reason_code: type: string - pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + enum: + - voluntary_resignation + - retirement_transition + - fixed_term_completion + - position_elimination + - employer_initiated_separation evidence_reference: type: string pattern: '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' From 1a094cbe78422a9c31812620c1b17484059dc123 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 09:17:15 +0900 Subject: [PATCH 246/269] build: reseal public separation vocabulary --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index bc4b88326..28f77f631 100644 --- a/manifest.json +++ b/manifest.json @@ -377,9 +377,9 @@ }, { "path": "schemas/openapi.yaml", - "sha256": "e73da7c826f9721853287f9515454c545c8128e63ab3517512d19a3c5dbcd85f", - "bytes": 33668, - "lines": 1144 + "sha256": "533ceb35786c97d8e5d589b6853192837ad420c5be87c33761a22d8399c54f4e", + "bytes": 33818, + "lines": 1149 }, { "path": "scripts/foundation-contract-core.mjs", From 4805eac029c5635839b55e5a2fa49fa2d759424b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:04:10 +0900 Subject: [PATCH 247/269] docs(people): publish governed employment separation API --- docs/API_CONTRACT.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 27235d9c9..df03a2e47 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -41,7 +41,25 @@ High-impact commands additionally require: For confirmed-hire materialization, those high-impact facts are resolved from the exact already-sealed `selection_decision` and its evidence set inside the tenant-bound transaction rather than accepted again as mutable request-body assertions. -The server rejects a reused idempotency key when its method, resource, tenant, actor, purpose, or semantic command digest differs. People employment, position, assignment, and confirmed-hire writes persist that digest on `people_mutation_idempotency_record` in the same transaction as the authoritative HRIS fact and audit/outbox pair. A matching retry returns the first committed record identity without duplicating authoritative or audit/outbox facts. Generated record identifiers are excluded from the employment/position/assignment digest so a retried POST that allocates fresh UUIDs still replays; the confirmed-hire route requires the caller to repeat the exact confirmed identities and rejects a same-key command whose materialization identities differ. +The server rejects a reused idempotency key when its method, resource, tenant, actor, purpose, or semantic command digest differs. People employment, position, assignment, confirmed-hire, and Employment-separation writes persist the corresponding digest in the same transaction as the authoritative HRIS fact and audit/outbox pair. A matching retry returns the first committed result without duplicating authoritative or audit/outbox facts. Generated record identifiers are excluded from the employment/position/assignment digest so a retried POST that allocates fresh UUIDs still replays; the confirmed-hire route requires the caller to repeat the exact confirmed identities and rejects a same-key command whose materialization identities differ. Employment separation binds the key to the exact tenant, Person, Employment, expected Employment version, effective separation date, controlled reason, actor, `workforce_admin` purpose, evidence version, and human confirmation; a semantic mismatch under the same key fails closed. + +## Governed Employment separation + +The active People contract on PR #64 introduces `POST /v1/employment-separations`. It is a high-impact governed lifecycle command, not an in-place status update. The command targets one exact current-known `active` or `leave` Employment version, requires human confirmation and versioned evidence, and produces bitemporal supersession plus immutable separation/audit/outbox/idempotency evidence in one PostgreSQL transaction. + +`separation_reason_code` is a controlled Ubiquitous-Language value. The public contract accepts exactly: + +- `voluntary_resignation` +- `retirement_transition` +- `fixed_term_completion` +- `position_elimination` +- `employer_initiated_separation` + +Arbitrary lower-`snake_case` text is not a valid reason. The route also fails closed for stale expected versions, tenant or Person/Employment mismatches, incompatible future Employment truth, or Assignment truth that would remain effective on or after the requested separation boundary. + +The continuation version's `effective_to` is only the structural end of the pre-separation interval. The terminal `employment_record_version` with status `terminated`, together with its `employment_separation_record`, is the authoritative separation fact. Rehire is not part of this route and remains planned under #302; it must not reopen a terminated Employment or treat an old candidate-worker conversion as rehire authority. + +This route is active-PR truth, not protected/released truth, until #64 is normally integrated and ADR 0015's PostgreSQL/security/review acceptance conditions are satisfied. Consumers must not treat the active branch as an immutable external dependency. ## Example endpoints @@ -50,6 +68,7 @@ POST /v1/person-records GET /v1/person-records/{person_record_id} POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire POST /v1/employment-records +POST /v1/employment-separations POST /v1/position-records POST /v1/assignment-records POST /v1/job-profiles @@ -60,7 +79,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, Employment-separation, position, assignment, job-profile, and selection-decision operations on the active #64 branch. 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. Employment separation additionally serializes with Assignment creation on the same Employment aggregate conflict boundary. ## Error shape @@ -73,4 +92,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. From 1476b48458abf8b6b13596c995aaab4e67d4e54a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:04:37 +0900 Subject: [PATCH 248/269] docs(people): model authoritative employment separation --- docs/DATA_MODEL.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 7d4afd563..ab805038c 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -8,6 +8,7 @@ | `person_record` | Durable person entity inside Orgmetra, not an authentication subject. | | `employment_record` | Durable employment identity for a person. | | `employment_record_version` | Bitemporal employment status, exclusive-or-concurrent code, and effective period. | +| `employment_separation_record` | Append-only governed separation provenance linking the exact prior Employment version, optional continuation version, terminal version, decision metadata, evidence, human confirmation, and immutable audit event. | | `organization_unit` | Durable organizational identity referenced by positions and hierarchy facts. | | `organization_unit_version` | Bitemporal organizational name, type, and parent relationship for an organization unit. | | `job_profile` | Durable job identity referenced by positions, criteria, and decisions. | @@ -18,7 +19,7 @@ | `candidate_profile` | Applicant/candidate record before hire. | | `candidate_worker_link` | Legacy append-only candidate-to-worker linkage retained for historical reads; new writes use `candidate_worker_conversion_record`. | | `candidate_worker_conversion_record` | Governed bitemporal candidate-to-worker conversion bound to the hire decision, person, employment, immutable audit event, and outbox evidence. | -| `people_mutation_idempotency_record` | Append-only tenant/route/idempotency-key binding to the canonical command digest and first committed created-record identity for governed People writes. | +| `people_mutation_idempotency_record` | Append-only tenant/route/idempotency-key binding to the canonical command digest and first committed created/result record identity for governed People writes. | | `criterion_blueprint` | Job-related performance criterion definition. | | `criterion_observation` | Observed criterion result. | | `decision_evidence_set` | Versioned evidence-set header whose database-computed digest and membership are sealed by one accountable selection decision. | @@ -52,6 +53,16 @@ Intervals are half-open and non-empty: an end value, when present, must be stric Durable anchors such as `organization_unit`, `job_profile`, `employment_record`, and `position_record` do not repeat mutable descriptive attributes. Their descriptive versions live in `organization_unit_version`, `job_profile_version`, `employment_record_version`, and `position_record_version`. Single-valued bitemporal version families reject overlapping effective/system intervals, so one `effective_from`/`effective_to` interval combined with one `recorded_from`/`recorded_to` interval cannot yield contradictory current descriptions. Corrections close the previous recorded interval and insert a replacement; in-place business mutation is rejected. +### Employment separation truth + +On the active #64 People branch, a governed Employment separation is a bitemporal correction of one exact current-known `active` or `leave` `employment_record_version`; it is not an UPDATE of the protected business columns. The database closes the expected version's recorded interval at one post-lock database-owned timestamp. When the requested separation date is later than the prior version's `effective_from`, an optional continuation version preserves `[effective_from, separation_effective_on)`. A terminal `employment_record_version` with status `terminated` owns `[separation_effective_on, infinity)`. + +`employment_separation_record` binds the prior version, optional continuation version, terminal version, controlled `separation_reason_code`, actor, `workforce_admin` purpose, evidence reference/version, human confirmation, recorded timestamp, and audit identity. The continuation version's `effective_to` is only an interval boundary. The terminal version plus its `employment_separation_record` is the single authoritative separation truth; no parallel `employment_transition` termination fact is introduced. + +Separation and Assignment INSERT serialize on the same `employment_record` aggregate anchor. After the anchor lock, Assignment coverage is re-read in a separate statement so a waiter cannot proceed from a pre-lock READ COMMITTED snapshot. Separation never edits Assignment-owned rows: an Assignment that remains effective on or after the separation boundary blocks the separation until the Assignment owner coordinates it. Historical Assignments ending at or before the boundary remain legal. + +ADR 0015 remains Proposed and this model is active-PR truth until #64 is normally integrated and its PostgreSQL/security/review acceptance conditions pass. Rehire remains planned under #302; it creates a new Employment for the existing Person after a protected successful separation rather than reopening the terminated Employment. + Assignments remain a legitimately multiple-membership fact. Each assignment must name the covering employment and the same person as that employment. Exclusive employments for one person cannot overlap; a second job must be marked `concurrent`. Allocation totals for one employment, and visible allocations for one position, are enforced by `orgmetra_hris_kernel` rather than a single-valued exclusion. An assignment day must also land on an `active` or `open` position version. ## High-impact decision evidence @@ -62,9 +73,9 @@ New predictive-validity membership uses `validity_study_case_record` rather than ## People mutation idempotency -`people_mutation_idempotency_record` is the durable retry boundary for governed candidate-worker conversion, Employment, Position, and Assignment mutations. Its unique business key is `(tenant_record_id, command_route, idempotency_key)`; the row stores the canonical semantic-command SHA-256 digest and the first committed created-record identity. Matching retries replay that identity, while a changed command under the same tenant/route/key fails closed instead of creating another HRIS fact. +`people_mutation_idempotency_record` is the durable retry boundary for governed candidate-worker conversion, Employment, Position, Assignment, and Employment-separation mutations. Its unique business key is `(tenant_record_id, command_route, idempotency_key)`; the row stores the canonical semantic-command SHA-256 digest and the first committed created/result record identity. Matching retries replay that identity, while a changed command under the same tenant/route/key fails closed instead of creating another HRIS fact. -The owning write port acquires an exact-key transaction-scoped advisory lock and writes the HRIS fact, immutable audit/outbox evidence, and idempotency row inside one PostgreSQL transaction. A rolled-back mutation therefore cannot leave a false replay marker. The relation is append-only, TRUNCATE-protected, tenant-RLS isolated, and uses opaque operational UUIDs. The idempotency key is transport correlation, not HR data or authorization evidence; actor, purpose, human-confirmation and resource authorization remain independently required. +The owning write port acquires an exact-key transaction-scoped advisory lock and writes the HRIS fact, immutable audit/outbox evidence, and idempotency row inside one PostgreSQL transaction. Separation additionally locks the Employment aggregate so distinct idempotency keys cannot create contradictory terminal truth. A rolled-back mutation therefore cannot leave a false replay marker. After an uncertain caller outcome, a fresh same-key request replays the first durable result rather than creating retry-only separation, audit, or outbox facts. The relation is append-only, TRUNCATE-protected, tenant-RLS isolated, and uses opaque operational UUIDs. The idempotency key is transport correlation, not HR data or authorization evidence; actor, purpose, human-confirmation and resource authorization remain independently required. ## Audit and outbox normalization @@ -82,4 +93,4 @@ Exponential/backoff policy selection, policy-specific producer configuration, re ## PII policy -PII is not globally masked. Instead, every sensitive read is evaluated against tenant, actor, role, purpose, resource, field sensitivity, legal basis, retention, and audit policy. Audit envelopes and escalation evidence store opaque references and governance codes instead of duplicating mutable employee or candidate payloads. +PII is not globally masked. Instead, every sensitive read is evaluated against tenant, actor, role, purpose, resource, field sensitivity, legal basis, retention, and audit policy. Audit envelopes, Employment-separation provenance, and escalation evidence store opaque references and governance codes instead of duplicating mutable employee or candidate payloads. From 9ff4f5bf56c50ff77e3c5a604fd77e2005bf71c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:05:03 +0900 Subject: [PATCH 249/269] docs(people): reconcile ERD with separation truth --- docs/ERD.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/ERD.md b/docs/ERD.md index a547cc3a0..11d9afa4d 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -1,6 +1,6 @@ # ERD -For readability, the diagram renders representative `tenant_record` scoping edges rather than repeating the same edge for every tenant-owned relation. The authoritative tenant-isolation contract is `docs/DATA_MODEL.md`: **every owned HRIS fact** stores `tenant_record_id`, every cross-table reference is tenant-qualified, and forced row-level security applies independently to every tenant-scoped table. This omission is visual only; it does not weaken the relational or authorization contract for employment, candidate, evidence, decision, validation-link, compensation, transition, audit, outbox, or outbox-escalation entities. +For readability, the diagram renders representative `tenant_record` scoping edges rather than repeating the same edge for every tenant-owned relation. The authoritative tenant-isolation contract is `docs/DATA_MODEL.md`: **every owned HRIS fact** stores `tenant_record_id`, every cross-table reference is tenant-qualified, and forced row-level security applies independently to every tenant-scoped table. This omission is visual only; it does not weaken the relational or authorization contract for employment, Employment separation, candidate, evidence, decision, validation-link, compensation, audit, outbox, or outbox-escalation entities. ```mermaid erDiagram @@ -9,9 +9,13 @@ erDiagram tenant_record ||--o{ job_profile : scopes tenant_record ||--o{ audit_event_record : scopes tenant_record ||--o{ outbox_delivery_escalation_record : scopes + tenant_record ||--o{ people_mutation_idempotency_record : scopes person_record ||--o{ person_name_record : has_names person_record ||--o{ employment_record : has employment_record ||--o{ employment_record_version : has_versions + employment_record ||--o{ employment_separation_record : has_separations + employment_record_version ||--o{ employment_separation_record : referenced_by + audit_event_record ||--o| employment_separation_record : proves organization_unit ||--o{ organization_unit_version : has_versions organization_unit_version }o--o| organization_unit : may_parent organization_unit ||--o{ position_record : contains @@ -39,8 +43,6 @@ erDiagram validity_study ||--o{ validity_study_evidence_set_link : preserves_evidence decision_evidence_set ||--o{ validity_study_evidence_set_link : supplies_evidence person_record ||--o{ compensation_record : has - employment_record ||--o{ employment_transition : changes_through - tenant_record ||--o{ people_mutation_idempotency_record : scopes audit_event_record ||--o{ outbox_delivery_record : delivers_through outbox_delivery_record ||--o| outbox_delivery_escalation_record : terminally_escalates ``` @@ -51,6 +53,14 @@ erDiagram Every owned HRIS fact carries `tenant_record_id`. Relationships that cross table boundaries use tenant-qualified foreign keys, and row-level security independently filters every tenant-scoped relation. The tenant column is therefore both a referential-integrity boundary and a runtime isolation boundary, not a caller-supplied business attribute. +### Employment separation + +The active #64 People branch models separation through `employment_separation_record`; the former conceptual `employment_transition` edge is not an authoritative termination relation. Each separation belongs to one durable `employment_record` and references exactly one prior `employment_record_version`, zero or one continuation version, and exactly one terminal version. The continuation exists only when the requested separation date is later than the prior version's `effective_from`; it preserves the pre-separation interval. The terminal version has status `terminated` and owns the interval beginning at the separation boundary. + +The prior/continuation/terminal references are role-specific foreign keys even though the compact ER diagram renders them through one `employment_record_version` relationship. The terminal version plus `employment_separation_record` is the authoritative separation truth. A continuation version's `effective_to` is structural interval closure, not a second termination fact. The separation record also binds the governed decision metadata and immutable `audit_event_record`; audit/outbox/idempotency and the bitemporal version change commit atomically. + +Assignment INSERT and Employment separation serialize on the same `employment_record` aggregate anchor. Separation does not own Assignment lifecycle and therefore does not close or rewrite `assignment_record`; a current/future Assignment that conflicts with the requested boundary causes the separation command to fail closed. ADR 0015 remains Proposed and these relations are active-PR truth until #64 reaches protected truth. Rehire is planned under #302 and is not represented as an implemented transition in this ERD. + A candidate profile can be linked to at most one worker identity within its tenant. A person identity can have multiple candidate-worker links across reapplications or historical candidate profiles, so the person-side cardinality is one-to-many. Each criterion observation belongs to one effective-dated performance cycle so reporting periods remain reconstructable across effective and system time. @@ -61,7 +71,7 @@ A `validity_study` connects the criterion blueprint to the exact selection decis One immutable `audit_event_record` may have multiple `outbox_delivery_record` rows when the same event must reach multiple delivery targets. The unique `(tenant_record_id, audit_event_record_id, delivery_target_code)` key permits at most one delivery lifecycle per target. Delivery retries mutate only the delivery relation; the canonical event bytes and digest are append-only and therefore cannot drift with transport state. -A `people_mutation_idempotency_record` belongs to one tenant and names one created employment, position, or assignment identity for one route and `Idempotency-Key`. The unique `(tenant_record_id, command_route, idempotency_key)` key prevents a retry from creating a second authoritative fact. Tenants do not share keys. +A `people_mutation_idempotency_record` belongs to one tenant and binds one canonical governed command result to one route and `Idempotency-Key`. Employment, Position and Assignment creation replay their first created identity; Employment separation replays its first terminal result. The unique `(tenant_record_id, command_route, idempotency_key)` key prevents a retry from creating a second authoritative fact. Tenants do not share keys. A delivery can have at most one `outbox_delivery_escalation_record`, enforced by the unique `(tenant_record_id, outbox_delivery_record_id)` key. The escalation row exists only for a terminal `dead_lettered` delivery and records the failure classification, terminal attempt count, recorded time, and an opaque operator/customer escalation reference without copying the event payload. The row is append-only; terminal queue history is not reopened or rewritten. From 5e0d863f106dc2e96181a0a350383c3aef2fcafe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:05:23 +0900 Subject: [PATCH 250/269] docs(people): show governed separation lifecycle --- docs/UML.md | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/UML.md b/docs/UML.md index efc8b23d6..a26ebc7ca 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -85,11 +85,52 @@ stateDiagram-v2 Offered --> Worker: human_confirmed_hire_accepted Worker --> Leave: leave_started Leave --> Worker: leave_ended - Worker --> FormerWorker: human_confirmed_employment_terminated - FormerWorker --> RehireCandidate: rehire_requested + Worker --> FormerWorker: governed_employment_separation [active PR #64] + Leave --> FormerWorker: governed_employment_separation [active PR #64] + note right of FormerWorker + Rehire is planned under #302. + No implemented transition is claimed here. + end note ``` -A second employment that overlaps an exclusive period is rejected unless it is marked `concurrent`. Rehire after a closed exclusive period returns to Worker through a new `employment_record`. +A second Employment that overlaps an exclusive period is rejected unless it is marked `concurrent`. The active #64 separation path ends one exact current-known Employment through bitemporal supersession; it does not rewrite the prior business fact in place. Rehire remains a downstream #302 contract and, when implemented, is expected to create a new `employment_record` for the existing Person after successful prior separation. Until that contract reaches protected truth, this UML deliberately shows no `FormerWorker -> Worker` or `FormerWorker -> RehireCandidate` production transition. + +## Governed Employment-separation sequence + +```mermaid +sequenceDiagram + actor HROps + participant Gateway + participant PeopleCore + participant EmploymentDB as People PostgreSQL + participant AssignmentBoundary + participant Audit + + HROps->>Gateway: Preview separation target, consequence, reason, evidence + Gateway-->>HROps: Exact Employment/version + controlled reason + evidence versions + HROps->>Gateway: Confirm(single-use confirmation, Idempotency-Key) + Gateway->>PeopleCore: POST /v1/employment-separations + PeopleCore->>PeopleCore: Bind tenant, actor, workforce_admin purpose and exact command digest + PeopleCore->>EmploymentDB: separate exact expected Employment version + EmploymentDB->>EmploymentDB: Lock idempotency key, then Employment aggregate + EmploymentDB->>EmploymentDB: Re-read current version and Assignment-conflict truth after lock + alt stale/future/conflicting Assignment truth + EmploymentDB-->>PeopleCore: Fail closed; no durable separation side effect + PeopleCore-->>Gateway: Governed conflict response + Gateway-->>HROps: Coordinate current truth and retry intentionally + else authoritative transition accepted + EmploymentDB->>EmploymentDB: Close recorded interval; add optional continuation + terminal version + EmploymentDB->>Audit: Persist employment_separated audit/outbox evidence in same transaction + EmploymentDB->>EmploymentDB: Append employment_separation_record + idempotency result + EmploymentDB-->>PeopleCore: First durable terminal result or exact-key replay + PeopleCore-->>Gateway: Governed separation receipt + Gateway-->>HROps: Recorded terminal Employment state + end +``` + +The diagram shows transaction ownership rather than service-to-service SQL. `AssignmentBoundary` remains the owner of Assignment lifecycle; Employment separation never closes or rewrites Assignment rows. Migration 0017 makes Assignment INSERT and separation serialize on the same Employment anchor, with a fresh post-lock coverage read. The separation database function is capability-separated behind dedicated owner/executor roles; application callers do not receive direct People/audit/outbox DML as a substitute. + +ADR 0015 remains Proposed. This sequence is active-PR truth on #64, not a protected/released capability, until canonical PostgreSQL execution under #311 and the remaining security/review gates pass. ## Hire-to-assignment sequence @@ -119,4 +160,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 +``` From 46d816c49c34c5557c343cf972108fc49081a445 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:05:43 +0900 Subject: [PATCH 251/269] docs(people): add governed separation product requirement --- docs/PRD.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/PRD.md b/docs/PRD.md index 674a3b96c..9f2b71a29 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -13,6 +13,7 @@ Current HR systems often separate job architecture, recruiting, assessment, empl - Did selection evidence predict later job performance? - Did performance criteria actually measure the job analysis model? - Did organizational context, manager, opportunity, or time distort the observed outcome? +- When an Employment ends, can the organization reconstruct the exact prior state, effective boundary, reason, evidence, accountable actor, confirmation, and later corrections without rewriting history? - Can HR act on PII without unsafe masking while remaining compliant and auditable? ## 3. Target users @@ -34,8 +35,9 @@ Current HR systems often separate job architecture, recruiting, assessment, empl 3. Record human selection decisions with explicit evidence, uncertainty, and constraints. 4. Convert a hired candidate into a worker without losing candidate evidence provenance. 5. Track assignments and performance outcomes over effective time and system time. -6. Validate whether selection tools predict job-relevant outcomes and whether they do so fairly. -7. Integrate specialist CWL services without destroying the HRIS source-of-truth boundary. +6. End an Employment through a human-confirmed, evidence-backed, bitemporal transition that preserves prior knowledge and does not silently rewrite Assignment-owned truth. +7. Validate whether selection tools predict job-relevant outcomes and whether they do so fairly. +8. Integrate specialist CWL services without destroying the HRIS source-of-truth boundary. ## 5. Scope @@ -50,6 +52,7 @@ Current HR systems often separate job architecture, recruiting, assessment, empl - Audit/provenance contract. - CWL integration adapter contracts. - Documentation and diagram baseline. +- Governed Employment-separation contract on active PR #64, with bitemporal supersession, controlled reasons, human confirmation, evidence, idempotency, audit/outbox atomicity, tenant isolation, and Assignment serialization. This item is not protected/released truth until #64 satisfies ADR 0015 acceptance and is normally integrated. ### P1 product slice @@ -74,6 +77,8 @@ Current HR systems often separate job architecture, recruiting, assessment, empl - Orgmetra is not the psychometric numerical kernel; fast-mlsirm owns that boundary. - Orgmetra is not the mailbox or calendar provider; Naruon and customer providers own that boundary. - Orgmetra does not directly query other products' application tables. +- Employment separation does not own payroll, identity deprovisioning, notification delivery, or Assignment lifecycle; those remain downstream owned contracts/events. +- Rehire is not implemented by the separation contract. It remains planned under #302 and must create a new Employment for the existing Person after protected prior-separation truth exists. ## 7. Functional requirements @@ -89,8 +94,15 @@ Current HR systems often separate job architecture, recruiting, assessment, empl | FR-008 | The system shall support purpose-bound access rather than global PII masking. | | FR-009 | The system shall integrate CWL services only through versioned APIs, events, packages, or adapters. | | FR-010 | The system shall distinguish shipped truth, active PR, accepted architecture, planned, research-only, superseded, and out-of-scope states in documentation. | +| FR-011 | The system shall support a governed Employment-separation command that targets one exact current-known Employment version, requires accountable human confirmation and versioned evidence, uses only the controlled separation-reason vocabulary, preserves bitemporal history, atomically records audit/outbox/idempotency provenance, and fails closed rather than creating contradictory current/future Assignment or Employment truth. | -## 8. Non-functional requirements +## 8. Employment-separation buyer acceptance + +The buyer-visible capability is an accountable end-of-Employment operation whose result can be reconstructed across effective time and recorded time. A valid receipt must identify the terminal Employment version and correspond to immutable separation/audit evidence; same-key retries converge on the first durable result, including after an uncertain caller outcome. The command must not convert a request failure into a partial separation, duplicate audit/outbox event, or orphaned idempotency marker. + +The active #64 implementation is not a release claim. Before this requirement can be marked protected/shipped, ADR 0015 remains Proposed until canonical PostgreSQL Foundation owner #311 executes the registered separation/Assignment-serialization acceptance roots/companions on an immutable candidate and the remaining required security and independent-review gates pass. No p95 claim is made here; production-capability latency must be measured separately under the repository performance contract before a buyer-facing SLO is asserted. + +## 9. Non-functional requirements - Auditability: every high-impact decision links to evidence and actor context. - Reliability: idempotent commands and explicit retry/compensation where integrations fail. @@ -99,11 +111,12 @@ Current HR systems often separate job architecture, recruiting, assessment, empl - Accessibility: WCAG 2.2 AA-oriented UI with exact-value tables for charts. - Scientific integrity: psychometric claims require validity evidence, not correlation-only shortcuts. -## 9. Success metrics +## 10. Success metrics - Time to create a reviewable job profile from evidence. - Percentage of hiring decisions with complete evidence lineage. - Percentage of candidate-worker links with preserved provenance. +- Percentage of governed Employment separations with complete actor/purpose/reason/evidence/confirmation lineage and replay-safe audit/outbox evidence after the capability reaches protected truth. - Criterion blueprint coverage by job family. - Criterion observations assigned to a valid effective-dated performance cycle. - Validity studies with predictor/criterion version linkage. From 08e6a2128aadebd44b608eed47af4ce9bb6bc01c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:06:16 +0900 Subject: [PATCH 252/269] docs(people): specify separation technical boundary --- docs/TRD.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/TRD.md b/docs/TRD.md index c6e5e5e2f..db3ed496b 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -17,7 +17,7 @@ Material mathematical and psychometric kernels must use bounded CPU multithreadi | Canonical service identifier | Responsibility | |---|---| -| `people_core` | Person identity anchors, names, employment, assignments, compensation, candidate-worker linkage, and identity references. | +| `people_core` | Person identity anchors, names, employment, governed Employment separation, assignments, compensation, candidate-worker linkage, and identity references. | | `organization_core` | Organization units, reporting relations, legal entities, locations, and positions. | | `job_architecture` | Job profiles, tasks, FJA, KSAO, qualification rules, evidence, and SME approvals. | | `talent_acquisition` | Requisitions, candidates, versioned decision-evidence sets, interviews, confirmations, and selection decisions. | @@ -38,6 +38,9 @@ These identifiers are canonical across deployment names, ACLs, metrics, generate - A finalized high-impact selection command binds one immutable evidence-set version and a database-computed SHA-256 digest over canonical sorted evidence membership; later evidence membership changes are rejected. - High-impact decision APIs return evidence sufficiency and escalation status. - Generated server validation must enforce the OpenAPI contract before domain handlers execute. +- `POST /v1/employment-separations` is a high-impact People command on active PR #64. It binds tenant, Person, Employment, exact expected Employment version, effective separation date, actor, `workforce_admin` purpose, human confirmation, evidence reference/version, controlled reason and idempotency key before persistence. +- `separation_reason_code` is an exact enum: `voluntary_resignation`, `retirement_transition`, `fixed_term_completion`, `position_elimination`, or `employer_initiated_separation`. A syntactically valid but unrecognized lower-`snake_case` value fails closed before mutation. +- Employment separation does not implement rehire. #302 remains planned downstream and cannot consume mutable #64 source as an external runtime dependency. ## 4. Event envelope @@ -61,6 +64,8 @@ These identifiers are canonical across deployment names, ACLs, metrics, generate `actor_reference` is required and resolves only inside the authorized tenant. `provenance_reference` is also required and resolves to an immutable audit bundle containing actor, policy decision, confirmation, reason, command digest, sealed evidence-set digest and evidence versions. Consumers must verify both fields before treating a high-impact event as accountable. +Employment separation emits the corresponding CloudEvents-compatible `employment_separated` audit envelope from database-owned post-lock recorded time. The audit/outbox fact, `employment_separation_record`, terminal bitemporal state and People idempotency result are one transaction; downstream workflow, payroll, identity deprovisioning and notification behavior begins only after that transaction through owned contracts/events. + ## 5. Data model rules - Stable entity anchors do not contain mutable descriptive attributes. @@ -74,10 +79,24 @@ These identifiers are canonical across deployment names, ACLs, metrics, generate - Assignment days must land on an `active` or `open` position version, and visible allocations for one seat cannot exceed 1.0000. - Model external organization roles as time-varying relations when one entity can be a customer, partner, competitor, or vendor in different contexts. - Keep assessment results as external immutable snapshot references unless a later ADR transfers instrument lifecycle ownership. -- Candidate-worker links, selection decisions, evidence-set membership after finalization, and validation-study decision/evidence/outcome links are append-only. +- Candidate-worker links, selection decisions, evidence-set membership after finalization, governed Employment-separation provenance, and validation-study decision/evidence/outcome links are append-only. - An open `decision_evidence_set` carries no caller-supplied digest. Selection finalization requires at least one member, computes the canonical SHA-256 digest inside PostgreSQL, and seals exactly one set in the same transaction; a sealed set cannot accept new members, be reused by a second decision, or point to a different consuming decision. - Validity studies reference exact selection decisions, sealed evidence sets and criterion observations through normalized link relations so criterion-related validity can be reconstructed without copying specialist-system payloads. +### 5.1 Employment-separation invariant + +The active #64 implementation follows ADR 0015 and remains active-PR, not protected/released, truth while that ADR is Proposed. + +- A separation corrects one exact current-known `active` or `leave` `employment_record_version`; it never rewrites the prior protected business columns in place. +- The database takes one post-lock recorded timestamp, closes the expected version's recorded interval, creates an optional continuation version for the pre-separation effective interval, and creates one terminal `terminated` version beginning at the requested separation boundary. +- The continuation version's `effective_to` is structural interval closure. The terminal version plus `employment_separation_record` is the authoritative separation fact. +- `employment_separation_record` tenant-qualifies and binds the prior version, optional continuation version, terminal version, controlled reason, evidence, human confirmation, actor/purpose, recorded timestamp and immutable audit identity. +- The exact tenant+route+idempotency key serializes matching/reused command identity. Distinct separation commands serialize on the durable `employment_record` aggregate anchor. +- Assignment INSERT and separation use the same Employment anchor as their database conflict boundary. After waiting on that lock, Assignment coverage must be re-read in a separate statement so READ COMMITTED cannot continue from a pre-separation statement snapshot. +- Separation never creates, closes, rewrites or deletes Assignment-owned rows. An Assignment effective on or after the requested boundary blocks separation until its owning boundary coordinates it; historical Assignment ending at or before the boundary remains valid. +- The separation function uses a dedicated `NOLOGIN`/`NOBYPASSRLS` SECURITY DEFINER owner, while a distinct executor capability receives function `EXECUTE` without direct People/audit/outbox table DML. Ordinary Assignment writers do not receive broad Employment UPDATE privilege solely for locking. +- Same-key replay after an uncertain caller outcome must return the first durable terminal result without retry-only separation/audit/outbox side effects. + ## 6. Integration adapters | Adapter | Target and contract | Owner | @@ -99,3 +118,7 @@ Adapters use bounded timeouts, typed error semantics, tenant validation, idempot ## 7. Testing requirements `docs/TEST_STRATEGY.md` is the canonical coverage and execution contract. Every service must satisfy its 100% statement/branch coverage requirement where the pinned toolchain exposes those metrics, document exact commands, and preserve migration, API, event, authorization, temporal, tenant-isolation, evidence-sealing, append-only, scientific, adapter-failure, and accessibility evidence. PostgreSQL contract tests use a `NOBYPASSRLS` application role and cover missing tenant context, cross-tenant references, concurrent bitemporal corrections, database-owned evidence digest computation, empty-evidence rejection, and post-decision evidence drift. This TRD does not define a weaker duplicate threshold. + +Before ADR 0015 can move from Proposed, canonical PostgreSQL Foundation execution must cover the Employment-separation root plus its same-database concurrency/cleanup/recovery companions and the Assignment/separation serialization root. Acceptance includes bitemporal historical reconstruction, atomic audit/outbox/idempotency durability, exact-key replay, semantic-key conflicts, stale/future/cross-tenant hostile cases, real PostgreSQL blocker graphs for exact-key and distinct-key races, both Assignment-first and separation-first commit orders, failure-path server-session quiescence, uncertain-commit recovery, FORCE RLS, and capability separation. Elapsed time alone is not concurrency evidence. + +The canonical execution owner is #311 after prerequisite Foundation integration. A GREEN legacy filename switchboard that does not execute the newly registered roots/companions is not evidence that these acceptance cases passed. Security/review gates remain independently required, and no protected release or latency SLO is implied by active-branch test success. From 22e7f265c13ed4cbb6b8559baf11f2de9f2cf8b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:11:15 +0900 Subject: [PATCH 253/269] chore(manifest): reseal separation documentation --- manifest.json | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/manifest.json b/manifest.json index 28f77f631..998e17915 100644 --- a/manifest.json +++ b/manifest.json @@ -161,21 +161,21 @@ }, { "path": "docs/API_CONTRACT.md", - "sha256": "63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589", - "bytes": 4555, - "lines": 76 + "sha256": "8b2114c60f083882f1aea3212bd5c421bada845bed18d7c7e8312f563d145165", + "bytes": 6705, + "lines": 95 }, { "path": "docs/DATA_MODEL.md", - "sha256": "6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a", - "bytes": 13366, - "lines": 85 + "sha256": "d553a9fa105f437ccb79bebec2d028ee0e108846dd3f6386bde9613c75b6236f", + "bytes": 15960, + "lines": 96 }, { "path": "docs/ERD.md", - "sha256": "546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe", - "bytes": 6964, - "lines": 70 + "sha256": "3ce3da4fd1c099fca1e115b83f59bf64a30fa1622e5fbd1cf516d8eff11691d6", + "bytes": 8992, + "lines": 80 }, { "path": "docs/OPERABILITY.md", @@ -185,9 +185,9 @@ }, { "path": "docs/PRD.md", - "sha256": "3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1", - "bytes": 5490, - "lines": 111 + "sha256": "9a4af723b1d039e11521a2e408ff746eb699b9bfceea9521f5c3636c1b523c96", + "bytes": 8283, + "lines": 124 }, { "path": "docs/SECURITY.md", @@ -227,15 +227,15 @@ }, { "path": "docs/TRD.md", - "sha256": "23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077", - "bytes": 9064, - "lines": 101 + "sha256": "cea2c7b9f1c0679177152af2fad7e17afe00f6229e6cd6e3888645bec5c46e59", + "bytes": 13588, + "lines": 124 }, { "path": "docs/UML.md", - "sha256": "fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9", - "bytes": 5528, - "lines": 122 + "sha256": "6cc63124d3a2ef1b2a91910386ad54ea13151a76483d1e276e587380f0d1b0f6", + "bytes": 8528, + "lines": 163 }, { "path": "docs/USER_STORIES.md", @@ -251,7 +251,7 @@ }, { "path": "docs/adr/0001-orgmetra-authoritative-hris-record.md", - "sha256": "0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572", + "sha256": "0f8055b73c63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", "bytes": 6108, "lines": 53 }, From e38fe69ba4a0082fe671f6cdd19b24c0d810aa39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:14:07 +0900 Subject: [PATCH 254/269] fix(manifest): restore canonical ADR seal --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 998e17915..203ea2908 100644 --- a/manifest.json +++ b/manifest.json @@ -251,7 +251,7 @@ }, { "path": "docs/adr/0001-orgmetra-authoritative-hris-record.md", - "sha256": "0f8055b73c63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "sha256": "0f8055b73c63bd3130321415ad53233588ff952aabd1a88952b39c71747253572", "bytes": 6108, "lines": 53 }, From 780ba278c42d749034f611e93e2d051dcf6ca989 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:18:19 +0900 Subject: [PATCH 255/269] fix(manifest): apply exact ADR digest --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 203ea2908..eaba78a41 100644 --- a/manifest.json +++ b/manifest.json @@ -251,7 +251,7 @@ }, { "path": "docs/adr/0001-orgmetra-authoritative-hris-record.md", - "sha256": "0f8055b73c63bd3130321415ad53233588ff952aabd1a88952b39c71747253572", + "sha256": "0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572", "bytes": 6108, "lines": 53 }, From 7f811937a976db0607e6030dd67fa43599ddb89d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:20:55 +0900 Subject: [PATCH 256/269] docs(security): reconcile governed separation boundary --- docs/SECURITY.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fd6dd3ea6..22ccd0d7c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -3,6 +3,7 @@ ## Trust boundaries - Orgmetra HRIS facts +- Governed Employment-separation provenance - External CWL service references - Document artifacts - LLM draft outputs @@ -17,7 +18,7 @@ - Purpose-bound authorization replaces indiscriminate masking. - Sensitive data access is auditable, tenant-scoped, exact-target-correlated, field-scoped, and bounded by an operation-specific Keyverse scope. - In-memory historical reconstruction and HRIS decision functions require explicit tenant scope; a caller-supplied collection containing colliding identifiers from another tenant cannot provide coverage, consume capacity, create false employment conflicts, or enter reconstructed history. -- Durable UUID identity columns reject the RFC 9562 Nil and Max sentinel values at the PostgreSQL boundary; reserved protocol sentinels cannot become tenant, person, employment, organization, job, position, assignment, candidate, decision, evidence, outcome, transition, audit-event, outbox-delivery, or outbox-escalation identities. +- Durable UUID identity columns reject the RFC 9562 Nil and Max sentinel values at the PostgreSQL boundary; reserved protocol sentinels cannot become tenant, person, employment, organization, job, position, assignment, candidate, decision, evidence, outcome, separation, audit-event, outbox-delivery, or outbox-escalation identities. - LLM outputs cannot mutate authoritative facts without human-approved commands. - External integrations use explicit adapters and fail closed. - Event payloads carry opaque references, not broad PII broadcasts. Durable audit persistence enforces an exact top-level event-field allowlist so a caller cannot expand the retained audit payload with employee names, compensation, free-text evidence, or other mutable HR facts. @@ -41,24 +42,32 @@ Authorization evidence contains only governance metadata, including the opaque a ## 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. +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, selection-decision, and Employment-separation command families require operation-specific authentication/authorization bindings. Employment, position, and assignment use `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 on active PR #64 implement employment, position, assignment, and governed Employment separation. 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. -All mutation families additionally require resource-scoped authorization and a versioned audit/provenance correlation reference. High-risk commands require an explicit human-confirmation boundary and immutable versioned evidence. Employment, position, and assignment commands carry confirmation/evidence on the command. Confirmed-hire materialization resolves the exact previously sealed `selection_decision` in the same tenant-bound transaction and rejects the mutation unless that decision records explicit human confirmation and sealed evidence provenance. +`POST /v1/employment-separations` is a high-impact lifecycle boundary. It requires the authenticated principal, tenant, exact Person and Employment identities, exact expected Employment version, `workforce_admin` purpose, one of the five reviewed `separation_reason_code` values, effective separation date, versioned evidence, single-use human confirmation, and an idempotency key. The command fails closed on tenant/Person/Employment mismatch, stale expected version, incompatible current/future Employment truth, or Assignment truth that would remain effective on or after the requested boundary. Separation does not accept free-form reasons and does not infer authority from an earlier candidate-worker conversion. -`people_mutation_idempotency_record` stores the tenant, route, idempotency key, semantic-command digest, committed resource identity, and transaction time in the same transaction as the authoritative HRIS fact and governed audit/outbox pair. A transaction-scoped advisory lock serializes concurrent requests for the exact tenant/route/key. A same-key same-command replay returns the first committed identity without repeating Person, Employment, candidate-worker conversion, audit, or outbox writes; a changed command under the same key fails closed. A rolled-back command leaves no successful replay marker. The idempotency relation is tenant-RLS isolated and append-only, including TRUNCATE protection. +All mutation families additionally require resource-scoped authorization and a versioned audit/provenance correlation reference. High-risk commands require an explicit human-confirmation boundary and immutable versioned evidence. Employment, position, assignment, and Employment-separation commands carry confirmation/evidence on the command. Confirmed-hire materialization resolves the exact previously sealed `selection_decision` in the same tenant-bound transaction and rejects the mutation unless that decision records explicit human confirmation and sealed evidence provenance. + +`people_mutation_idempotency_record` stores the tenant, route, idempotency key, semantic-command digest, committed resource/result identity, and transaction time in the same transaction as the authoritative HRIS fact and governed audit/outbox pair. A transaction-scoped advisory lock serializes concurrent requests for the exact tenant/route/key. A same-key same-command replay returns the first committed identity/result without repeating Person, Employment, candidate-worker conversion, separation, audit, or outbox writes; a changed command under the same key fails closed. A rolled-back command leaves no successful replay marker. After an uncertain caller outcome, a fresh matching separation command must return the first durable terminal result and must not create retry-only separation, audit, or outbox facts. The idempotency relation is tenant-RLS isolated and append-only, including TRUNCATE protection. + +Employment separation has an additional aggregate-conflict boundary that is independent of exact-key idempotency. Distinct separation commands lock the durable `employment_record`, and Assignment INSERT uses the same Employment anchor. After any lock wait, current Employment/Assignment coverage is re-read in a new statement so READ COMMITTED cannot authorize a mutation from a pre-lock statement snapshot. Separation owns no Assignment row and therefore cannot close, update, or delete an Assignment as a side effect. + +The database execution boundary is capability-separated. The separation transition function is owned by a dedicated `NOLOGIN`/`NOBYPASSRLS` SECURITY DEFINER owner and executed through a distinct narrowly privileged capability; the application executor does not receive direct People/audit/outbox DML as a substitute for the function. Ordinary Assignment writers do not receive broad Employment UPDATE privilege merely to participate in aggregate serialization. Migration must fail closed rather than reuse reserved capability-role names with unknown memberships or ACLs. A caller-controlled purpose value cannot substitute for a missing token scope. The OpenAPI contract is executable input to generated gateway and server validation; an implementation that accepts a request outside its published contract fails CI. Internal traces remain in restricted telemetry. Customer-facing failures return a bounded `error_code`, actionable `message`, `next_action`, and random `support_reference`; the support lookup is access-controlled and retention-bound. -The same governance contract applies to selection decisions, compensation changes, terminations, promotions, job-profile publication, validation-study policy changes, data exports, and identity deprovisioning. Draft creation may use a narrower permission, but publication or authoritative state transition may not reuse draft-only authorization. +The same governance contract applies to selection decisions, compensation changes, Employment separations, promotions, job-profile publication, validation-study policy changes, data exports, and identity deprovisioning. Draft creation may use a narrower permission, but publication or authoritative state transition may not reuse draft-only authorization. + +ADR 0015 remains Proposed. The separation boundary described here is active-PR truth until #64 is normally integrated and its canonical PostgreSQL, security, and independent-review acceptance passes. Rehire #302 is not an implemented privilege path and must not reopen a terminated Employment or consume this mutable branch as an external runtime dependency. ## High-risk action flow -1. **Review/Preview**: show target, consequences, actor, tenant, purpose, reason, and exact evidence versions. +1. **Review/Preview**: show target, consequences, actor, tenant, purpose, controlled reason, and exact evidence versions. 2. **Confirm**: obtain an explicit, single-use confirmation reference from an authorized human. -3. **Record**: append the authoritative decision and evidence references under one idempotency key. +3. **Record**: append the authoritative decision or state transition and evidence references under one idempotency key. For Employment separation this means bitemporal supersession plus immutable `employment_separation_record`, not an in-place status rewrite. 4. **Audit**: in the same business transaction, persist `AuditOutboxEvent.canonical_json()` plus its SHA-256 digest through `record_audit_outbox_event(...)`; PostgreSQL revalidates the allowlisted PII-minimized envelope, event/tenant binding, digest, and high-impact confirmation before a pending outbox row is created. 5. **Deliver**: asynchronous workers may mutate only guarded outbox delivery state. They may complete or retry only their exact live lease while budget remains. After the immutable database-owned attempt budget is durably exhausted, claim/retry cannot create another attempt; the exact recorded stable worker identity may append matching immutable escalation evidence and terminalize through the normal worker function. If that identity is permanently lost, only an explicitly provisioned `orgmetra_outbox_operator` capability may invoke the separate expired-lease recovery function; its fresh NOLOGIN/NOBYPASSRLS function owner performs narrowly granted transport DML, while the externally assignable operator role itself cannot select or update outbox rows or insert escalation rows directly. The deferred escalation-binding check is forced while SECURITY DEFINER privileges are still active and returned to deferred mode before control returns to the caller. Workers and operators cannot select a lower terminal threshold, fabricate nonterminal escalation evidence, reopen terminal rows, rewrite audit evidence, or infer a successful downstream receipt. -No LLM, integration adapter, or background worker may synthesize the human confirmation or transition a candidate to `Offered` or `Worker` autonomously. +No LLM, integration adapter, background worker, or model-backed workflow may synthesize human confirmation, create a governed Employment separation, or transition a candidate to `Offered` or `Worker` autonomously. From ffcf4adde2b9116d2c7e6a4e89ad262ad801e592 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:21:18 +0900 Subject: [PATCH 257/269] docs(security): model employment separation threats --- docs/THREAT_MODEL.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 29d11a478..f0cc20d9f 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -4,20 +4,26 @@ | Threat | Example | Preventive control | Detection evidence and required test | |---|---|---|---| -| Spoofing | External identity treated as HR person identity | Separate `person_record` from the Keyverse subject; verify issuer, audience, tenant, actor binding, and token lifetime. | Authentication-denial audit; tests reject subject/person substitution and stale authorization. | -| Tampering | Selection evidence, audit envelope, terminal escalation evidence, or delivery history changed after decision | Append-only decision/evidence records; database-sealed evidence digest; append-only `audit_event_record` and `outbox_delivery_escalation_record`; immutable database-owned delivery retry budget; database recomputation of SHA-256 over exact canonical audit bytes; guarded delivery state stored separately. | Integrity alert; tests reject update/delete, digest mismatch, added audit fields, illegal outbox state changes, retry-budget mutation/dispatcher override, attempt N+1, terminal-row reopening, escalation mutation/fabrication, and version mismatch. | -| Repudiation | Hiring manager or integration operator denies a decision or terminal delivery failure | Human confirmation reference, actor, tenant, purpose, reason, evidence versions, immutable audit event, recorded dispatcher identity, and immutable terminal escalation reference with durable attempt count. | Correlated decision/audit/escalation lookup; tests prove actor traceability, prohibit high-impact audit persistence without confirmation, and preserve one append-only escalation record for dead-lettered work. | -| Information disclosure | PII broadcast, over-broad field response, or retained data through an event bus or failure queue | Opaque references, exact durable audit-field allowlist, purpose-bound tenant/resource/operation/scope/field authorization before protected values leave the HR boundary, tenant-scoped encryption, and no copied mutable HR payload in audit or escalation evidence. | Payload scanner plus authorization/database contracts; tests reject extra employee-name/PII event fields, foreign-resource access, missing operation scope, disallowed fields, and malformed or wildcard-like authorization attributes while constraining escalation metadata to governance codes/references. | -| Cross-tenant access | Tenant A reads, reconstructs, changes, or emits evidence for Tenant B HRIS facts by altering a path, header, reference, cache key, event, delivery, escalation, or by supplying a foreign fact with a colliding durable identifier to an in-memory decision. | Authenticated tenant context, explicit agreement among request/actor/resource/policy tenants, explicit tenant scope in historical reconstruction and HRIS decision functions, audit event/tenant identity binding, forced RLS on audit/outbox/escalation relations, service-owned database roles, tenant-aware cache keys, and consumer-side event validation. | `cross_tenant_access_denied` audit event with no sensitive values; authorization/integration/kernel/database tests attempt request/actor/resource tenant mismatches, direct reads/writes, object-reference swaps, colliding identifiers, event tenant mismatch, foreign delivery finalization/escalation, cache poisoning, and replay across tenants and require denial or exclusion with unchanged target data. | -| Denial of service | Integration retries flood services, a permanently rejected event loops forever, or final-attempt ownership is lost | Idempotency, guarded outbox leasing, immutable bounded database-owned retry-attempt budget with terminal dead-letter removal, no claim/retry after exhaustion, stable final-attempt worker identity, bounded exponential backoff and queue limits before production dispatcher release, circuit breaking, and per-tenant budgets. | Queue-depth/lease/retry/dead-letter telemetry; load and recovery tests must prove bounded work, pre-exhaustion expired-lease recovery, stored retry-budget exhaustion, no attempt N+1, exhausted-final-lease non-reclaimability, recorded-owner terminalization, and fair tenant isolation before dispatcher release. | -| Elevation of privilege | A purpose header, broad token, LLM, or foreign dispatcher grants itself access or finalizes another worker's high-impact/transport state | Purpose-bound authorization requires exact tenant/resource/purpose/operation matching, operation-specific Keyverse scope, and field minimization; LLM outputs remain draft evidence; human confirmation controls HR writes; dispatcher completion/retry require exact live lease ownership; dead-lettering requires exhausted durable budget plus the exact recorded final-attempt worker identity, and never accepts a dispatcher-selected retry threshold. | Authorization-denial audit; tests reject purpose-only authorization, missing scopes, disallowed fields, cross-tenant resources, LLM decision records, missing-confirmation audit events, `Offered`/`Worker` transitions, foreign/stale completion or retry, dispatcher retry-budget override, direct premature terminal DML, replacement-worker claim after exhaustion, foreign dead-lettering, fabricated nonterminal escalation evidence, and premature dead-lettering. | +| Spoofing | External identity treated as HR person identity, or a caller separates an Employment that belongs to another Person/tenant | Separate `person_record` from the Keyverse subject; verify issuer, audience, tenant, actor binding, token lifetime, and exact tenant/Person/Employment identity before a high-impact mutation. | Authentication-denial audit; tests reject subject/person substitution, stale authorization, tenant/Person/Employment mismatch, and cross-tenant separation targets. | +| Tampering | Selection evidence, governed separation provenance, audit envelope, terminal escalation evidence, or delivery history changed after decision | Append-only decision/evidence and `employment_separation_record`; bitemporal Employment supersession instead of in-place business-column mutation; database-sealed evidence digest; append-only `audit_event_record` and `outbox_delivery_escalation_record`; immutable database-owned delivery retry budget; database recomputation of SHA-256 over exact canonical audit bytes; guarded delivery state stored separately. | Integrity alert; tests reject update/delete, stale expected Employment version, unrecognized separation reason, digest mismatch, added audit fields, illegal outbox state changes, retry-budget mutation/dispatcher override, attempt N+1, terminal-row reopening, escalation mutation/fabrication, and version mismatch. | +| Repudiation | Hiring manager, HR operator, or integration operator denies a decision, Employment separation, or terminal delivery failure | Human confirmation reference, actor, tenant, purpose, controlled reason, evidence versions, immutable `employment_separation_record`, immutable audit event, recorded dispatcher identity, and immutable terminal escalation reference with durable attempt count. | Correlated decision/separation/audit/escalation lookup; tests prove actor/reason/evidence traceability, prohibit high-impact persistence without confirmation, preserve one append-only separation provenance record for the terminal Employment result, and preserve one append-only escalation record for dead-lettered work. | +| Information disclosure | PII broadcast, over-broad field response, or retained data through an event bus, separation audit, or failure queue | Opaque references, exact durable audit-field allowlist, purpose-bound tenant/resource/operation/scope/field authorization before protected values leave the HR boundary, tenant-scoped encryption, and no copied mutable HR payload in separation/audit/escalation evidence. | Payload scanner plus authorization/database contracts; tests reject extra employee-name/PII event fields, foreign-resource access, missing operation scope, disallowed fields, and malformed or wildcard-like authorization attributes while constraining separation/escalation metadata to governance codes/references. | +| Cross-tenant access | Tenant A reads, reconstructs, changes, separates, or emits evidence for Tenant B HRIS facts by altering a path, header, reference, cache key, event, delivery, escalation, or by supplying a foreign fact with a colliding durable identifier to an in-memory decision | Authenticated tenant context, explicit agreement among request/actor/resource/policy tenants, explicit tenant scope in historical reconstruction and HRIS decision functions, tenant-qualified separation/version/audit/idempotency foreign keys, audit event/tenant identity binding, forced RLS on tenant-scoped relations, service-owned database roles, tenant-aware cache keys, and consumer-side event validation. | `cross_tenant_access_denied` audit event with no sensitive values; authorization/integration/kernel/database tests attempt request/actor/resource tenant mismatches, direct reads/writes, object-reference swaps, colliding identifiers, foreign Employment/Person separation, event tenant mismatch, foreign delivery finalization/escalation, cache poisoning, and replay across tenants and require denial or exclusion with unchanged target data. | +| Denial of service | Integration retries flood services, separation retries create duplicate terminal facts, a permanently rejected event loops forever, or final-attempt ownership is lost | Idempotency, exact-key advisory serialization, Employment-aggregate serialization for distinct separation commands, guarded outbox leasing, immutable bounded database-owned retry-attempt budget with terminal dead-letter removal, no claim/retry after exhaustion, stable final-attempt worker identity, bounded exponential backoff and queue limits before production dispatcher release, circuit breaking, and per-tenant budgets. | Queue-depth/lease/retry/dead-letter telemetry plus separation concurrency evidence; tests must prove same-key replay, distinct-key blocking on the Employment aggregate, one durable terminal separation, failure-path session quiescence, uncertain-outcome recovery, bounded work, pre-exhaustion expired-lease recovery, stored retry-budget exhaustion, no attempt N+1, exhausted-final-lease non-reclaimability, recorded-owner terminalization, and fair tenant isolation. | +| Elevation of privilege | A purpose header, stale recruiting conversion, broad token, LLM, foreign dispatcher, or direct table writer grants itself access or finalizes another worker's high-impact/transport state | Purpose-bound authorization requires exact tenant/resource/purpose/operation matching, operation-specific Keyverse scope, and field minimization; Employment separation requires `workforce_admin`, exact target/version, controlled reason, versioned evidence and human confirmation; the transition is capability-separated behind a dedicated NOLOGIN/NOBYPASSRLS SECURITY DEFINER owner/executor boundary; LLM outputs remain draft evidence; dispatcher completion/retry require exact live lease ownership; dead-lettering requires exhausted durable budget plus the exact recorded final-attempt worker identity and never accepts a dispatcher-selected retry threshold. | Authorization-denial audit; tests reject purpose-only authorization, missing scopes, disallowed fields, cross-tenant resources, stale conversion as mutation authority, LLM decision/separation records, missing confirmation, direct separation-table or Employment-version DML through the executor, `Offered`/`Worker` transitions, foreign/stale completion or retry, dispatcher retry-budget override, direct premature terminal DML, replacement-worker claim after exhaustion, foreign dead-lettering, fabricated nonterminal escalation evidence, and premature dead-lettering. | +| Inconsistent concurrent truth | An Assignment is inserted from a pre-lock READ COMMITTED snapshot while another transaction separates the same Employment, or two different separation keys both attempt terminal truth | Assignment INSERT and Employment separation lock the same durable `employment_record` aggregate anchor; after waiting, coverage is re-read in a separate statement; stale expected versions and conflicting current/future Assignment truth fail closed. Separation does not own or silently mutate Assignment rows. | Real two-session PostgreSQL tests inspect `pg_blocking_pids(...)` and `transactionid`/tuple waits, exercise Assignment-first and separation-first commit orders, and require exactly one consistent durable outcome rather than elapsed-time inference. | +| Partial/ambiguous commit | Database commit becomes durable but the separation caller loses the response and retries, or a client/session dies while a competing transaction is blocked | Separation/audit/outbox/idempotency commit atomically; same semantic key replays the first durable result; retry-only evidence is forbidden; failure cleanup verifies both client and PostgreSQL server-session quiescence before teardown. | Uncertain-commit recovery uses a fresh connection to prove the first durable state before terminating the original backend, then replays the same key and requires one terminal separation/audit/outbox/idempotency result. Failure-path tests require zero durable side effects after aborted pre-commit sessions and zero lingering server sessions. | ## Model risk -LLM analysis may summarize, extract, or draft but cannot publish job profiles, approve selection, alter compensation, revise performance policy, or change employment state without a human decision record. +LLM analysis may summarize, extract, or draft but cannot publish job profiles, approve selection, alter compensation, revise performance policy, create a governed Employment separation, or change employment state without a human decision record. Model-backed workflows do not receive the separation executor capability and cannot manufacture the confirmation/evidence boundary. ## Data risk Blanket masking can break HR work. Orgmetra uses purpose-bound access, encryption, retention, audit, and export control so authorized users can work with required PII safely. The purpose-bound decision evaluates request, authenticated actor, resource, and policy tenant identity before resource/purpose/operation/scope/field attributes; decisions retain governance metadata and field names rather than protected field values. Event and telemetry surfaces minimize PII even when the authoritative service is permitted to display it. +`employment_separation_record` retains governed provenance and opaque evidence/confirmation references rather than duplicating mutable employee PII. The terminal `employment_record_version` plus this provenance record is the authoritative separation fact; the optional continuation version's `effective_to` is only an interval boundary. Rehire remains planned under #302 and is not a second path that may reopen or rewrite terminal truth. + `audit_event_record` keeps only the allowlisted governance envelope; delivery retries, leases, immutable retry budget, and terminal state live in `outbox_delivery_record`; immutable dead-letter escalation metadata lives in `outbox_delivery_escalation_record`. This separation prevents mutable transport coordination or terminal operator evidence from rewriting or expanding retained audit evidence. Exhausted final-attempt ownership remains bound to the recorded stable worker reference under the normal worker path; if that identity is permanently unavailable, the implemented `orgmetra_outbox_operator` capability may invoke only the guarded expired-final-lease recovery function while remaining unable to read/update outbox rows or insert escalation evidence directly. + +ADR 0015 remains Proposed. These separation threats and controls describe active PR #64, not protected/released capability, until canonical PostgreSQL execution and required security/review gates complete. From 5b86bb5fe266669d581446544be733bb5237dd95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:23:02 +0900 Subject: [PATCH 258/269] chore(manifest): reseal separation security docs --- manifest.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/manifest.json b/manifest.json index eaba78a41..3ad74629c 100644 --- a/manifest.json +++ b/manifest.json @@ -191,9 +191,9 @@ }, { "path": "docs/SECURITY.md", - "sha256": "01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac", - "bytes": 11185, - "lines": 64 + "sha256": "7067c097e77e41d72d63f7d2d6bcb78a0619d5b4c749e950b51288123e7bde9f", + "bytes": 13988, + "lines": 73 }, { "path": "docs/STORYBOARD.md", @@ -215,9 +215,9 @@ }, { "path": "docs/THREAT_MODEL.md", - "sha256": "f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252", - "bytes": 6736, - "lines": 23 + "sha256": "bbd0bc9a60c80028cc4e2d196e449d85c92f3c3757dd492f6d7d658d586af06b", + "bytes": 10559, + "lines": 29 }, { "path": "docs/TRACEABILITY.md", From 9f42c5cff0071a9947f9a409a31a35a78e2582f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:24:24 +0900 Subject: [PATCH 259/269] docs(test): register separation acceptance matrix --- docs/TEST_STRATEGY.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index c20813b72..a58e5b80d 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -18,7 +18,7 @@ The command runs Python repository-integrity validation, the dependency-free Nod |---|---| | Required artifacts, manifest SHA-256/byte/line integrity, package metadata, Markdown, license, database naming, tenant/evidence/audit/dispatcher DDL fragments | `npm run validate` | | Structural OpenAPI 3.2 operation ownership: exact scopes, mutation headers, request-schema binding, evidence requirements, human confirmation, creation `Location` headers, required response codes, and client-safe error fields | `node --test tests/openapi-contract.test.mjs` (also included in `npm run validate`) | -| Every dispatcher migration and executable PostgreSQL contract remains present in both Python and Node provenance inventories | `node --test tests/dispatcher-inventory.test.mjs` (also included in `npm run validate`) | +| Every migration and executable PostgreSQL contract remains present in both Python and Node provenance inventories | `node --test tests/dispatcher-inventory.test.mjs` (also included in `npm run validate`) | | Bitemporal non-overlap, observably concurrent conflicting version insert, retroactive correction, and in-place rewrite rejection for versioned and other recorded-time HRIS facts | `bash tests/test_bitemporal_postgres.sh` against PostgreSQL 16 in Foundation CI | | Cross-tenant composite-FK rejection, missing-context fail-closed RLS reads and writes, cross-context write rejection, and tenant-visible row isolation over every current HRIS table | `bash tests/test_tenant_isolation_postgres.sh` against PostgreSQL 16 in Foundation CI | | Open-set caller-digest rejection, non-empty evidence enforcement, independently precomputed SHA-256 membership digest, membership/finalization race serialization, post-decision evidence-insert rejection, and evidence-set single-use enforcement | `bash tests/test_evidence_sealing_postgres.sh` against PostgreSQL 16 in Foundation CI | @@ -31,12 +31,22 @@ The command runs Python repository-integrity validation, the dependency-free Nod | Predictive-validity study case worker/decision/evidence/criterion and recorded-time integrity | `bash tests/test_validity_study_case_postgres.sh` against PostgreSQL 16 in Foundation CI | | 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 | +| Governed Employment separation: exact expected version, bitemporal prior/optional-continuation/terminal reconstruction, controlled reason/evidence/confirmation, audit/outbox/idempotency atomicity, same-key replay, stale/future/cross-tenant hostile cases, Assignment conflict fail-closed, and rollback | `bash tests/test_employment_separation_postgres.sh` against PostgreSQL 16 under canonical #311 discovery/execution | +| Separation executor capability: FORCE RLS, NOBYPASSRLS, reserved-role collision failure, narrowly granted SECURITY DEFINER owner/executor boundary, and denial of direct People/audit/outbox DML | `bash tests/test_employment_separation_capability_postgres.sh` against PostgreSQL 16 under canonical #311 discovery/execution | +| Distinct separation keys against one Employment serialize on the Employment aggregate, not merely the idempotency key; the loser observes stale truth and produces no durable side effects | `bash tests/test_employment_separation_distinct_key_concurrency.sh` as a same-database companion of `test_employment_separation_postgres.sh` under #311 | +| Aborted pre-commit separation races clean up both client and PostgreSQL server sessions and leave zero separation/audit/outbox/idempotency side effects | `bash tests/test_employment_separation_failure_cleanup.sh` as a same-database companion under #311 | +| Uncertain caller outcome after a durable separation commit replays the first terminal result on a fresh same-key request and creates no retry-only separation/audit/outbox facts | `bash tests/test_employment_separation_uncertain_commit_recovery.sh` as a same-database companion under #311 | +| Assignment INSERT and Employment separation serialize on the same Employment aggregate, re-read coverage after lock acquisition, and produce consistent truth in both Assignment-first and separation-first commit orders | `bash tests/test_assignment_separation_serialization_postgres.sh` against PostgreSQL 16 under canonical #311 discovery/execution | | Tenant/actor/purpose authorization matrix and negative high-impact commands | service-specific unit and integration test commands recorded in each service package | | 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 | -The PostgreSQL scripts apply the checked-in migration chain required by the contract under test to a fresh database. The bitemporal and evidence-sealing tests execute concurrency regressions with an observable database barrier instead of a fixed scheduling assumption. The tenant-isolation test proves both read and write enforcement with unprivileged `NOLOGIN NOBYPASSRLS` roles, so table-owner/superuser bypass cannot manufacture a passing tenant result. The evidence-sealing test compares database output with independently precomputed canonical SHA-256 fixtures and forces a membership transaction to hold the evidence-set row lock before finalization, proving the digest snapshot includes evidence that committed first. The audit/outbox contract stores exact `AuditOutboxEvent.canonical_json()` bytes, independently verifies their SHA-256 digest in PostgreSQL, rejects extra top-level PII fields even when a caller recomputes the digest, and exercises outbox lifecycle invariants separately from immutable audit facts. The outbox-claim contract proves an already-expired lease cannot be created, verifies deterministic tenant-scoped claims return the immutable event/digest while live leases are excluded, then lets a valid one-second lease expire and requires atomic takeover of that same row with attempt count 2, a new future lease, and explicit `lease_expired` evidence. The dead-letter contract applies migrations 0001 through 0007, proves the dispatcher cannot select its own terminal attempt budget, rejects direct terminal DML before matching immutable escalation evidence and the stored budget are satisfied, exercises the real retry/claim path through the database-owned default fifth attempt, rejects retry at attempt five, lets the final lease expire, proves a replacement worker cannot create attempt six, proves the row remains bound to the recorded worker identity, rejects a foreign finalizer, permits that exact recorded identity to append terminal evidence after expiry, and rejects fabricated escalation evidence for nonterminal work. The People mutation idempotency contract applies the authoritative migration chain through 0012, verifies the replay record is written in the same transaction as its authoritative fact and audit/outbox evidence, proves rollback leaves no false replay marker, and uses concurrent exact-key sessions to prove one canonical committed identity wins without duplicate business facts. Foundation CI executes every matrix entry independently; a cancelled, skipped, queued, absent, neutral, failed, stale, predecessor-head, status-only, or model-only matrix result is not database evidence for the current head. +The PostgreSQL scripts apply the checked-in migration chain required by the contract under test to a fresh database. The bitemporal and evidence-sealing tests execute concurrency regressions with an observable database barrier instead of a fixed scheduling assumption. The tenant-isolation test proves both read and write enforcement with unprivileged `NOLOGIN NOBYPASSRLS` roles, so table-owner/superuser bypass cannot manufacture a passing tenant result. The evidence-sealing test compares database output with independently precomputed canonical SHA-256 fixtures and forces a membership transaction to hold the evidence-set row lock before finalization, proving the digest snapshot includes evidence that committed first. The audit/outbox contract stores exact `AuditOutboxEvent.canonical_json()` bytes, independently verifies their SHA-256 digest in PostgreSQL, rejects extra top-level PII fields even when a caller recomputes the digest, and exercises outbox lifecycle invariants separately from immutable audit facts. The outbox-claim contract proves an already-expired lease cannot be created, verifies deterministic tenant-scoped claims return the immutable event/digest while live leases are excluded, then lets a valid one-second lease expire and requires atomic takeover of that same row with attempt count 2, a new future lease, and explicit `lease_expired` evidence. The dead-letter contract applies migrations 0001 through 0007, proves the dispatcher cannot select its own terminal attempt budget, rejects direct terminal DML before matching immutable escalation evidence and the stored budget are satisfied, exercises the real retry/claim path through the database-owned default fifth attempt, rejects retry at attempt five, lets the final lease expire, proves a replacement worker cannot create attempt six, proves the row remains bound to the recorded worker identity, rejects a foreign finalizer, permits that exact recorded identity to append terminal evidence after expiry, and rejects fabricated escalation evidence for nonterminal work. The People mutation idempotency contract applies the authoritative migration chain through 0012, verifies the replay record is written in the same transaction as its authoritative fact and audit/outbox evidence, proves rollback leaves no false replay marker, and uses concurrent exact-key sessions to prove one canonical committed identity wins without duplicate business facts. + +Employment-separation acceptance deliberately goes beyond exact-key idempotency. The main root applies the authoritative migration chain through the separation migrations; distinct-key and Assignment/separation races use real PostgreSQL two-session blocker evidence (`pg_blocking_pids(...)` and transaction/tuple waits) rather than elapsed-time sleeps. The failure-cleanup companion proves both local clients and database sessions quiesce before teardown. The uncertain-commit companion qualifies the first result through durable state observed from an independent connection, terminates the original caller, and requires a fresh same-key replay to converge without retry-only facts. These companions must run in the same disposable database as their owning root where their setup contract requires it. + +The legacy #64 Foundation filename switchboard does not currently execute every newly registered separation root/companion. Its GREEN result proves only the contracts it actually invokes. Canonical #311 owns generic immutable-candidate discovery/execution after prerequisite Foundation integration; only an exact-head #311 run that admits and executes the registered separation/serialization roots and companions may be cited as canonical PostgreSQL acceptance. A cancelled, skipped, queued, absent, neutral, failed, stale, predecessor-head, status-only, model-only, or merely provenance-listed contract is not database evidence for the current head. Future service packages must publish their exact test, statement-coverage, branch-coverage, docstring, typecheck, and build commands in the package manifest and CI log. @@ -44,13 +54,18 @@ Future service packages must publish their exact test, statement-coverage, branc Required negative and provenance tests include: -- an LLM or orchestration credential cannot create a selection-decision record; -- an LLM or integration adapter cannot transition `Candidate` to `Offered` or `Offered` to `Worker`; -- missing or insufficient Keyverse scope, actor, tenant, purpose, reason, confirmation, evidence reference, or evidence version fails closed; +- an LLM or orchestration credential cannot create a selection-decision or governed Employment-separation record; +- an LLM or integration adapter cannot transition `Candidate` to `Offered`, `Offered` to `Worker`, or an Employment to terminal separation truth; +- missing or insufficient Keyverse scope, actor, tenant, purpose, controlled reason, confirmation, evidence reference, or evidence version fails closed; +- Employment separation rejects wrong tenant, wrong Person, wrong Employment, stale expected version, already superseded/closed truth, an unrecognized reason code, or Assignment truth that conflicts with the requested boundary before authoritative mutation/audit; - mutation authentication and tenant binding occur before request-body reads or identifier allocation, so unauthenticated input cannot consume parser or persistence work; - a reused confirmation or idempotency key cannot bind to different command content; -- an identical tenant/route/idempotency-key retry replays the first committed created-record identity rather than issuing a duplicate authoritative write; +- an identical tenant/route/idempotency-key retry replays the first committed created/result identity rather than issuing a duplicate authoritative write; - concurrent exact-key requests serialize at the persistence boundary and cannot commit two different identities; +- two distinct separation keys targeting the same expected Employment version serialize on the Employment aggregate and cannot commit contradictory terminal truth; +- Assignment creation and separation against the same Employment exercise both commit orders and cannot authorize from a pre-lock READ COMMITTED snapshot; +- an aborted pre-commit separation race leaves no separation/audit/outbox/idempotency facts and no lingering client/server session; +- a durable separation followed by caller failure is recoverable through fresh-connection same-key replay without retry-only side effects; - previewed evidence versions must equal recorded evidence versions; - an open evidence set rejects a caller-supplied digest, preventing a client assertion from masquerading as database-observed membership; - finalizing a selection decision requires at least one versioned evidence member, computes the canonical SHA-256 digest in PostgreSQL, and seals exactly one evidence set in the same transaction; From 4981c807ce56e44d42e2381080f72159f390558c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:24:56 +0900 Subject: [PATCH 260/269] docs(ops): define employment separation recovery --- docs/OPERABILITY.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 31f3ff23e..ef17d3e03 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -6,18 +6,35 @@ - High-impact command audit append success: 99.99% within accepted maintenance windows. - Integration adapter error visibility: every failed outbound command produces an operator-safe event. +No buyer-facing Employment-separation latency SLO is asserted on the active branch. The `POST /v1/employment-separations` path must first be measured under the repository performance contract on a production-representative capability; test-fixture elapsed time is not a substitute for p95 evidence. + ## Degraded modes ### Keyverse unavailable - An already authenticated session may perform only low-risk, non-PII reads for at most 15 minutes after its last successfully verified authorization snapshot. - The 15-minute authorization lifetime is a hard upper bound. It cannot be renewed from a cached token, local clock extension, or an unavailable Keyverse response. -- PII reads, exports, role changes, identity provisioning, identity deprovisioning, and every high-risk command fail closed whenever current authorization cannot be verified. +- PII reads, exports, role changes, identity provisioning, identity deprovisioning, and every high-risk command, including Employment separation, fail closed whenever current authorization cannot be verified. - New sessions, new grants, and privilege elevation are rejected. - Revocation and deprovisioning requests are durably queued with idempotency keys, but the affected subject is denied Orgmetra access immediately until Keyverse confirms completion. - Every denied or deferred action records an `authorization_verification_unavailable` audit event with tenant, actor, purpose, resource, policy-snapshot time, and correlation reference. - Recovery requires a fresh Keyverse verification before a session regains PII or mutation capability; queued revocation and deprovisioning commands are reconciled before normal provisioning resumes. +### Governed Employment separation + +The active #64 separation path is a short PostgreSQL transaction. It must not wait inside the Employment lock for payroll, identity deprovisioning, document processing, notification delivery, an LLM, or any other external workflow. Those consumers start only from the committed versioned contract/event after authoritative People truth exists. + +- A failed transaction before commit must leave the prior current Employment version intact and leave no `employment_separation_record`, separation audit/outbox row, or successful idempotency marker. +- Exact-key retries are recovery behavior, not a reason to keep a transaction open. If the caller loses the outcome after the database commit is already durable, a fresh same-semantic request must replay the first terminal result without creating retry-only separation/audit/outbox facts. +- Distinct separation keys and Assignment INSERTs that target the same Employment serialize on the minimal durable `employment_record` aggregate anchor. Operators must diagnose lock waits with PostgreSQL session/lock state rather than elapsed-time heuristics. Broad table locks are not an accepted recovery measure. +- After a lock wait, the owning function re-reads current Employment/Assignment coverage in a new statement. A stale expected version or newly conflicting Assignment is a governed conflict, not an instruction to retry blindly. +- Separation never repairs a conflict by changing Assignment-owned rows. An active/future Assignment that crosses the proposed separation boundary is coordinated through the Assignment owner contract before a new separation command is attempted. +- Client cancellation or a failed concurrent attempt is not considered cleaned up until both the local client process and its PostgreSQL server session have quiesced. Teardown must not race a still-running waiter that could acquire a released lock and commit after the test/operator assumes failure. +- The dedicated separation executor capability must remain narrowly provisioned. A role collision, missing FORCE RLS/NOBYPASSRLS guarantee, or unexpected direct People/audit/outbox DML privilege is a deployment failure requiring operator review, not an automatic role reuse or privilege expansion. +- Rehire #302 is downstream of protected separation truth. Operators must not reactivate/reopen a terminated Employment or reuse an old candidate-worker conversion as an operational workaround. + +Recovery evidence for this path consists of the exact terminal/current Employment versions, `employment_separation_record`, People idempotency row, immutable audit/outbox evidence, and the database-owned recorded timestamp. Free-form termination narratives or copied employee documents are not operational evidence. + ### Audit/outbox persistence - An accepted business mutation that requires audit evidence must call `record_audit_outbox_event(...)` inside the same PostgreSQL transaction as the authoritative write. A failure to append the audit/outbox pair is a business-transaction failure, not a warning-only condition. @@ -52,15 +69,20 @@ - HRIS PostgreSQL requires encrypted backups, point-in-time recovery, and restore rehearsals. - Audit/provenance records require immutability and tamper evidence; restored audit rows must recompute to their stored SHA-256 digests before they are treated as review evidence. +- Employment separation recovery must restore the Employment version chain, `employment_separation_record`, People idempotency row, and its immutable audit/outbox evidence to one consistent recovery point. A restore that exposes a terminal Employment without its governed separation provenance, or separation provenance without its referenced terminal/prior versions, is not serviceable. - Outbox delivery state and escalation evidence must be restored together with the corresponding audit records. Recovery may retry non-terminal work but must not mutate or reopen a terminal delivered/dead-lettered record or invent a successful delivery receipt. - Object-store artifacts require tenant-scoped retention and deletion policy. -- Restored data is not serviceable until tenant isolation, temporal interval, append-only/TRUNCATE guards, evidence-reference, audit-envelope digest, outbox-state/escalation, trusted search-path, and manifest integrity checks pass. +- Restored data is not serviceable until tenant isolation, temporal interval, append-only/TRUNCATE guards, evidence-reference, separation-version/provenance binding, audit-envelope digest, outbox-state/escalation, trusted search-path, and manifest integrity checks pass. ## Incident classes - authorization verification or revocation failure - cross-tenant access attempt - evidence or audit-envelope integrity failure +- Employment-separation version/provenance inconsistency +- Employment-separation/Assignment concurrency inconsistency +- lingering separation client/server session or unexpected aggregate lock retention +- separation uncertain-outcome replay divergence or duplicate terminal side effect - outbox lease/retry/dead-letter or delivery-state corruption - missing or tampered outbox escalation evidence - lost final-attempt dispatcher identity requiring audited operator recovery @@ -69,3 +91,5 @@ - LLM draft hallucination detected - validation study discrepancy - migration reconciliation failure + +ADR 0015 remains Proposed. The separation operating contract above describes active PR #64 and must not be represented as protected/released service behavior until canonical PostgreSQL acceptance and the remaining security/review gates pass. From fb5f5c8a5e4b1c37eba0968e14fda598d7638298 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:28:28 +0900 Subject: [PATCH 261/269] chore(manifest): reseal separation acceptance docs --- manifest.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/manifest.json b/manifest.json index 3ad74629c..d5c0387b0 100644 --- a/manifest.json +++ b/manifest.json @@ -179,9 +179,9 @@ }, { "path": "docs/OPERABILITY.md", - "sha256": "82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62", - "bytes": 11189, - "lines": 71 + "sha256": "5743ac3b19da47acfd08f65f0a9125bff5775fa619bf2022f08dd0e880ce4003", + "bytes": 15247, + "lines": 95 }, { "path": "docs/PRD.md", @@ -209,9 +209,9 @@ }, { "path": "docs/TEST_STRATEGY.md", - "sha256": "d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8", - "bytes": 16534, - "lines": 135 + "sha256": "c3bd4be0ac6671cc4909a65723c5c846a3bf1c06ea0b0462b387ddfa27be5382", + "bytes": 20692, + "lines": 150 }, { "path": "docs/THREAT_MODEL.md", From 7899754e4e02a2b968e3e09ff9fbc14b21f728a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:29:54 +0900 Subject: [PATCH 262/269] docs(security): preserve published header contract --- docs/SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 22ccd0d7c..2dd5dc597 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -42,7 +42,7 @@ Authorization evidence contains only governance metadata, including the opaque a ## 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, selection-decision, and Employment-separation command families require operation-specific authentication/authorization bindings. Employment, position, and assignment use `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 on active PR #64 implement employment, position, assignment, and governed Employment separation. 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. +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. Active PR #64 additionally implements governed Employment separation as a distinct high-impact route whose authentication/authorization contract is described below. 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. `POST /v1/employment-separations` is a high-impact lifecycle boundary. It requires the authenticated principal, tenant, exact Person and Employment identities, exact expected Employment version, `workforce_admin` purpose, one of the five reviewed `separation_reason_code` values, effective separation date, versioned evidence, single-use human confirmation, and an idempotency key. The command fails closed on tenant/Person/Employment mismatch, stale expected version, incompatible current/future Employment truth, or Assignment truth that would remain effective on or after the requested boundary. Separation does not accept free-form reasons and does not infer authority from an earlier candidate-worker conversion. From dbf0f8e03cb27d799509815f1f4904efffda4627 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:32:22 +0900 Subject: [PATCH 263/269] chore(manifest): reseal security contract --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index d5c0387b0..7d9874010 100644 --- a/manifest.json +++ b/manifest.json @@ -191,8 +191,8 @@ }, { "path": "docs/SECURITY.md", - "sha256": "7067c097e77e41d72d63f7d2d6bcb78a0619d5b4c749e950b51288123e7bde9f", - "bytes": 13988, + "sha256": "897e51bd2ee20efe06b7f776729858094f0468f61013503afb0b0d2b71734578", + "bytes": 14041, "lines": 73 }, { From 10aa9158639a6f26573d5f2093140bd4f8c58c6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:35:11 +0900 Subject: [PATCH 264/269] docs(changelog): record governed employment separation --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9670ff0d..4cc62a4cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Orgmetra will be documented in this file. - Accepted ADRs 0001–0003 now include buyer-facing Context, Decision, and Consequences grounded in verified ISO 30400:2022, ISO 30414:2025, Uniform Guidelines (29 C.F.R. Part 1607), SIOP (2018), OpenAPI Specification v3.2.0, OpenID Connect Core 1.0 errata set 2, CloudEvents v1.0.2, Jensen and Snodgrass (1999), Snodgrass (1999), and Allen (1983) records already listed in `docs/doctoring/REFERENCES.md`. ADRs 0004 and 0005 gained APA 7th References pointers to that same bibliography without changing their Decision bodies. - Active-PR governed Job Analysis persistence/API on the canonical `JobAnalysisSnapshot` model: migration `0013_job_analysis_snapshot.sql` stores immutable tenant-scoped snapshot, Task, KSAO, Task–KSAO, FJA and write-command evidence; `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots` and matching GET enforce purpose-bound Keyverse scope, authenticated-principal actor authority, bounded/strict JSON handling, transactional Idempotency-Key serialization, parent-scope fail-closed integrity, forced RLS, and atomic audit/outbox evidence. ADR 0014 records the persistence decision while ADR 0007 remains the domain/evidence authority; validated evidence still requires accountable human review and non-LLM provenance, and the service does not make a high-impact employment decision. +- Active-PR governed Employment separation via `POST /v1/employment-separations`: exact current-known Employment/version targeting, five controlled separation reasons, human-confirmed versioned evidence, bitemporal supersession instead of in-place termination, append-only `employment_separation_record`, atomic People idempotency plus audit/outbox evidence, capability-separated PostgreSQL execution, and shared Employment-anchor serialization with Assignment creation. ADR 0015 remains Proposed; canonical #311 PostgreSQL execution, remaining security/review gates, protected integration, and downstream #302 rehire are not claimed by this Unreleased entry. - 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. - Bitemporal tenant-scoped organization hierarchy validation that rejects visible indirect parent cycles and reuses single-valued recorded-time reconstruction before graph traversal. @@ -69,7 +70,7 @@ All notable changes to Orgmetra will be documented in this file. - Expired dispatcher ownership is recoverable only after the recorded lease deadline and while attempts remain; takeover preserves immutable event/audit identity and retry budget, increments the attempt count, issues a new future lease, and records `lease_expired` failure evidence. An expired final-attempt row is not reclaimable beyond the budget. - Normal dead-lettering remains restricted to the exact recorded worker identity after the immutable database-owned retry budget is exhausted. If that final worker identity is permanently unavailable, a distinct operator-only function may terminalize only an already-expired exhausted lease, must supply an opaque operator reference and failure code, and must append matching immutable escalation evidence before the existing transition guard accepts the dead-letter state. `PUBLIC` cannot execute the function; the externally assignable `orgmetra_outbox_operator` role receives EXECUTE only, while a separate NOLOGIN/NOBYPASSRLS `orgmetra_outbox_recovery_owner` owns the SECURITY DEFINER function and narrowly scoped table privileges. The operator role cannot SELECT/UPDATE outbox state or INSERT escalation evidence directly. Pre-existing reserved role names are rejected before migration 0008 changes project objects, and the temporary schema `CREATE` used for ownership transfer is transactionally revoked before the role setup can commit. - Audit/outbox SQL boundaries pin a trusted `pg_catalog, public, pg_temp` search path while `PUBLIC` loses schema-creation privilege on `public`; immutable envelope validation uses C-collated deterministic comparisons and calendar-field validation rather than session-sensitive timestamp input parsing. -- Bitemporal reconstruction plus assignment, position-seat, employment-exclusivity, and organization-hierarchy kernel decisions are tenant-scoped so foreign-tenant identifiers cannot leak historical facts, provide coverage, consume capacity, or create false conflicts. +- Bitemporal reconstruction plus assignment, position-seat, employment-exclusivity, and organization-hierarchy kernel decisions are tenant-scoped so foreign-tenant identifiers cannot leak historical facts, provide coverage, consume capacity, create false conflicts, or enter reconstructed history. - Keyverse outage policy that blocks PII and high-risk actions when current authorization cannot be verified. - Cross-tenant threat, denial evidence, and negative authorization test contracts. - Replaced client-visible internal trace identifiers with random support references and actionable next-step error guidance. From d9cc516d54b4642f59fe126c331a19945fdf75f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 10:37:41 +0900 Subject: [PATCH 265/269] chore(manifest): adopt changelog provenance --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 7d9874010..2ff6fa09d 100644 --- a/manifest.json +++ b/manifest.json @@ -29,9 +29,9 @@ }, { "path": "CHANGELOG.md", - "sha256": "613702432c44c9e198371951d13fd70324617947fa87594c9d6aaf57bdc9ef72", - "bytes": 17968, - "lines": 79 + "sha256": "8687ead43d9e9f2216372e01121542f91e6a27b5f4b736f4ae59743b25000785", + "bytes": 18657, + "lines": 80 }, { "path": "CLAUDE.md", From f4f7e12d5edd253203c237d3a46703ca7449a8ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 17:08:38 +0900 Subject: [PATCH 266/269] fix(people): pin separation digest resolution --- database/migrations/0014_employment_separation_transition.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/database/migrations/0014_employment_separation_transition.sql b/database/migrations/0014_employment_separation_transition.sql index 00ac02f2f..2416392ab 100644 --- a/database/migrations/0014_employment_separation_transition.sql +++ b/database/migrations/0014_employment_separation_transition.sql @@ -255,7 +255,7 @@ BEGIN END IF; v_command_digest := encode( - digest( + public.digest( convert_to( jsonb_build_object( 'actor_reference', p_actor_reference, @@ -459,7 +459,7 @@ BEGIN || '"time":' || pg_catalog.to_json(v_event_time)::text || ',' || '"type":"orgmetra.people.employment_separated"}'; v_event_envelope_digest := encode( - digest(convert_to(v_canonical_event_json, 'UTF8'), 'sha256'), + public.digest(convert_to(v_canonical_event_json, 'UTF8'), 'sha256'), 'hex' ); From 71d72267be22d3368517a8171b61321592bfb958 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 17:10:58 +0900 Subject: [PATCH 267/269] test(people): prove separation tenant capability boundary --- ...ployment_separation_capability_postgres.sh | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_employment_separation_capability_postgres.sh b/tests/test_employment_separation_capability_postgres.sh index 1436bebc6..c3895c34f 100644 --- a/tests/test_employment_separation_capability_postgres.sh +++ b/tests/test_employment_separation_capability_postgres.sh @@ -79,6 +79,33 @@ if [[ "${capability_role_contract}" != "${expected_role_contract}" ]]; then exit 1 fi +capability_database_owner_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT count(*) +FROM pg_catalog.pg_database AS database_record +JOIN pg_catalog.pg_roles AS owner_role + ON owner_role.oid = database_record.datdba +WHERE database_record.datname = pg_catalog.current_database() + AND owner_role.rolname IN ( + 'orgmetra_employment_separation_executor', + 'orgmetra_employment_separation_owner' + ); +")" +if [[ "${capability_database_owner_count}" != "0" ]]; then + echo "Employment separation capability role unexpectedly owns the database" >&2 + exit 1 +fi + +capability_schema_create="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT + pg_catalog.has_schema_privilege('orgmetra_employment_separation_executor', 'public', 'CREATE')::text + || '|' + || pg_catalog.has_schema_privilege('orgmetra_employment_separation_owner', 'public', 'CREATE')::text; +")" +if [[ "${capability_schema_create}" != "false|false" ]]; then + echo "Employment separation capability role unexpectedly retains CREATE on public: ${capability_schema_create}" >&2 + exit 1 +fi + function_security_contract="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " SELECT function_record.prosecdef::text || '|' || owner_role.rolname FROM pg_catalog.pg_proc AS function_record @@ -128,6 +155,40 @@ if [[ "${executor_direct_dml}" != "false" ]]; then exit 1 fi +set +e +tenant_mismatch_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +SET ROLE orgmetra_employment_separation_executor; +SET orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; +SELECT * +FROM public.separate_employment_record_once( + '10000000-0000-7000-8000-000000000002'::uuid, + '00000000-0000-7000-8000-000000000001'::uuid, + '00000000-0000-7000-8000-000000000101'::uuid, + '00000000-0000-7000-8000-000000000201'::uuid, + DATE '2026-06-01', + 'voluntary_resignation', + 'separation_packet:tenant_mismatch_probe', + 'v1', + 'keyverse_subject:tenant_mismatch_probe', + 'workforce_admin', + 'human_confirmation:tenant_mismatch_probe', + 'employment-separation-tenant-mismatch-probe', + '00000000-0000-4000-8000-000000000501'::uuid, + '00000000-0000-4000-8000-000000000601'::uuid +); +SQL +} 2>&1)" +tenant_mismatch_status=$? +set -e +if [[ ${tenant_mismatch_status} -eq 0 ]]; then + echo "mismatched tenant GUC unexpectedly crossed Employment separation" >&2 + exit 1 +fi +if [[ "${tenant_mismatch_output}" != *"employment separation tenant context does not match command tenant"* ]]; then + echo "mismatched tenant GUC failed for an unexpected reason: ${tenant_mismatch_output}" >&2 + exit 1 +fi + set +e executor_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' SET ROLE orgmetra_employment_separation_executor; From 120616b1de33c1ff5c0de09dceb77dfea06691b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 17:12:39 +0900 Subject: [PATCH 268/269] docs(security): state separation tenant GUC trust boundary --- docs/THREAT_MODEL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index f0cc20d9f..25b64615d 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -8,7 +8,7 @@ | Tampering | Selection evidence, governed separation provenance, audit envelope, terminal escalation evidence, or delivery history changed after decision | Append-only decision/evidence and `employment_separation_record`; bitemporal Employment supersession instead of in-place business-column mutation; database-sealed evidence digest; append-only `audit_event_record` and `outbox_delivery_escalation_record`; immutable database-owned delivery retry budget; database recomputation of SHA-256 over exact canonical audit bytes; guarded delivery state stored separately. | Integrity alert; tests reject update/delete, stale expected Employment version, unrecognized separation reason, digest mismatch, added audit fields, illegal outbox state changes, retry-budget mutation/dispatcher override, attempt N+1, terminal-row reopening, escalation mutation/fabrication, and version mismatch. | | Repudiation | Hiring manager, HR operator, or integration operator denies a decision, Employment separation, or terminal delivery failure | Human confirmation reference, actor, tenant, purpose, controlled reason, evidence versions, immutable `employment_separation_record`, immutable audit event, recorded dispatcher identity, and immutable terminal escalation reference with durable attempt count. | Correlated decision/separation/audit/escalation lookup; tests prove actor/reason/evidence traceability, prohibit high-impact persistence without confirmation, preserve one append-only separation provenance record for the terminal Employment result, and preserve one append-only escalation record for dead-lettered work. | | Information disclosure | PII broadcast, over-broad field response, or retained data through an event bus, separation audit, or failure queue | Opaque references, exact durable audit-field allowlist, purpose-bound tenant/resource/operation/scope/field authorization before protected values leave the HR boundary, tenant-scoped encryption, and no copied mutable HR payload in separation/audit/escalation evidence. | Payload scanner plus authorization/database contracts; tests reject extra employee-name/PII event fields, foreign-resource access, missing operation scope, disallowed fields, and malformed or wildcard-like authorization attributes while constraining separation/escalation metadata to governance codes/references. | -| Cross-tenant access | Tenant A reads, reconstructs, changes, separates, or emits evidence for Tenant B HRIS facts by altering a path, header, reference, cache key, event, delivery, escalation, or by supplying a foreign fact with a colliding durable identifier to an in-memory decision | Authenticated tenant context, explicit agreement among request/actor/resource/policy tenants, explicit tenant scope in historical reconstruction and HRIS decision functions, tenant-qualified separation/version/audit/idempotency foreign keys, audit event/tenant identity binding, forced RLS on tenant-scoped relations, service-owned database roles, tenant-aware cache keys, and consumer-side event validation. | `cross_tenant_access_denied` audit event with no sensitive values; authorization/integration/kernel/database tests attempt request/actor/resource tenant mismatches, direct reads/writes, object-reference swaps, colliding identifiers, foreign Employment/Person separation, event tenant mismatch, foreign delivery finalization/escalation, cache poisoning, and replay across tenants and require denial or exclusion with unchanged target data. | +| Cross-tenant access | Tenant A reads, reconstructs, changes, separates, or emits evidence for Tenant B HRIS facts by altering a path, header, reference, cache key, event, delivery, escalation, or by supplying a foreign fact with a colliding durable identifier to an in-memory decision | Authenticated tenant context, explicit agreement among request/actor/resource/policy tenants, explicit tenant scope in historical reconstruction and HRIS decision functions, tenant-qualified separation/version/audit/idempotency foreign keys, audit event/tenant identity binding, forced RLS on tenant-scoped relations, service-owned database roles, tenant-aware cache keys, and consumer-side event validation. The PostgreSQL tenant session GUC is set only after the People service has completed Keyverse-authenticated tenant/actor binding; the separation executor is a service capability, not an end-user tenant credential. | `cross_tenant_access_denied` audit event with no sensitive values; authorization/integration/kernel/database tests attempt request/actor/resource tenant mismatches, direct reads/writes, object-reference swaps, colliding identifiers, foreign Employment/Person separation, event tenant mismatch, foreign delivery finalization/escalation, cache poisoning, and replay across tenants and require denial or exclusion with unchanged target data. The separation capability contract also requires a GUC/command-tenant mismatch to fail before target lookup. | | Denial of service | Integration retries flood services, separation retries create duplicate terminal facts, a permanently rejected event loops forever, or final-attempt ownership is lost | Idempotency, exact-key advisory serialization, Employment-aggregate serialization for distinct separation commands, guarded outbox leasing, immutable bounded database-owned retry-attempt budget with terminal dead-letter removal, no claim/retry after exhaustion, stable final-attempt worker identity, bounded exponential backoff and queue limits before production dispatcher release, circuit breaking, and per-tenant budgets. | Queue-depth/lease/retry/dead-letter telemetry plus separation concurrency evidence; tests must prove same-key replay, distinct-key blocking on the Employment aggregate, one durable terminal separation, failure-path session quiescence, uncertain-outcome recovery, bounded work, pre-exhaustion expired-lease recovery, stored retry-budget exhaustion, no attempt N+1, exhausted-final-lease non-reclaimability, recorded-owner terminalization, and fair tenant isolation. | | Elevation of privilege | A purpose header, stale recruiting conversion, broad token, LLM, foreign dispatcher, or direct table writer grants itself access or finalizes another worker's high-impact/transport state | Purpose-bound authorization requires exact tenant/resource/purpose/operation matching, operation-specific Keyverse scope, and field minimization; Employment separation requires `workforce_admin`, exact target/version, controlled reason, versioned evidence and human confirmation; the transition is capability-separated behind a dedicated NOLOGIN/NOBYPASSRLS SECURITY DEFINER owner/executor boundary; LLM outputs remain draft evidence; dispatcher completion/retry require exact live lease ownership; dead-lettering requires exhausted durable budget plus the exact recorded final-attempt worker identity and never accepts a dispatcher-selected retry threshold. | Authorization-denial audit; tests reject purpose-only authorization, missing scopes, disallowed fields, cross-tenant resources, stale conversion as mutation authority, LLM decision/separation records, missing confirmation, direct separation-table or Employment-version DML through the executor, `Offered`/`Worker` transitions, foreign/stale completion or retry, dispatcher retry-budget override, direct premature terminal DML, replacement-worker claim after exhaustion, foreign dead-lettering, fabricated nonterminal escalation evidence, and premature dead-lettering. | | Inconsistent concurrent truth | An Assignment is inserted from a pre-lock READ COMMITTED snapshot while another transaction separates the same Employment, or two different separation keys both attempt terminal truth | Assignment INSERT and Employment separation lock the same durable `employment_record` aggregate anchor; after waiting, coverage is re-read in a separate statement; stale expected versions and conflicting current/future Assignment truth fail closed. Separation does not own or silently mutate Assignment rows. | Real two-session PostgreSQL tests inspect `pg_blocking_pids(...)` and `transactionid`/tuple waits, exercise Assignment-first and separation-first commit orders, and require exactly one consistent durable outcome rather than elapsed-time inference. | @@ -22,6 +22,8 @@ LLM analysis may summarize, extract, or draft but cannot publish job profiles, a Blanket masking can break HR work. Orgmetra uses purpose-bound access, encryption, retention, audit, and export control so authorized users can work with required PII safely. The purpose-bound decision evaluates request, authenticated actor, resource, and policy tenant identity before resource/purpose/operation/scope/field attributes; decisions retain governance metadata and field names rather than protected field values. Event and telemetry surfaces minimize PII even when the authoritative service is permitted to display it. +The PostgreSQL tenant session GUC is a routing/row-policy input, not an authentication factor. The People service must derive and set it only after Keyverse-authenticated request, actor, and tenant binding. A principal that can directly assume `orgmetra_employment_separation_executor` is therefore inside the trusted service database boundary and must not be treated as tenant-safe merely because FORCE RLS is enabled: that capability can choose a session GUC. Deployment acceptance consequently proves that the separation executor and owner do not own the database, retain no `CREATE` privilege on `public`, and are not exposed as end-user or model-backed credentials. The SECURITY DEFINER transition still compares the active tenant GUC with the command tenant and rejects a mismatch before target lookup. + `employment_separation_record` retains governed provenance and opaque evidence/confirmation references rather than duplicating mutable employee PII. The terminal `employment_record_version` plus this provenance record is the authoritative separation fact; the optional continuation version's `effective_to` is only an interval boundary. Rehire remains planned under #302 and is not a second path that may reopen or rewrite terminal truth. `audit_event_record` keeps only the allowlisted governance envelope; delivery retries, leases, immutable retry budget, and terminal state live in `outbox_delivery_record`; immutable dead-letter escalation metadata lives in `outbox_delivery_escalation_record`. This separation prevents mutable transport coordination or terminal operator evidence from rewriting or expanding retained audit evidence. Exhausted final-attempt ownership remains bound to the recorded stable worker reference under the normal worker path; if that identity is permanently unavailable, the implemented `orgmetra_outbox_operator` capability may invoke only the guarded expired-final-lease recovery function while remaining unable to read/update outbox rows or insert escalation evidence directly. From c0ba482c57a77820128f259b4a43c06df643c0f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 17:14:58 +0900 Subject: [PATCH 269/269] chore(provenance): reseal separation hardening evidence --- manifest.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/manifest.json b/manifest.json index 2ff6fa09d..7835ab9a4 100644 --- a/manifest.json +++ b/manifest.json @@ -137,8 +137,8 @@ }, { "path": "database/migrations/0014_employment_separation_transition.sql", - "sha256": "8b7ce61220b55b260e432c26a66cdc16a744e8625778ca75095e7ba76bdabd43", - "bytes": 21853, + "sha256": "2e5a04671b6b47aa4263121d79c0b297ac9f1325c7e4a236bb55af8408c5ce6b", + "bytes": 21867, "lines": 537 }, { @@ -215,9 +215,9 @@ }, { "path": "docs/THREAT_MODEL.md", - "sha256": "bbd0bc9a60c80028cc4e2d196e449d85c92f3c3757dd492f6d7d658d586af06b", - "bytes": 10559, - "lines": 29 + "sha256": "792e5cca14840ff33e15034fe7af0d412a23785e8b451b3b0f37e90561074352", + "bytes": 11692, + "lines": 31 }, { "path": "docs/TRACEABILITY.md", @@ -449,9 +449,9 @@ }, { "path": "tests/test_employment_separation_capability_postgres.sh", - "sha256": "c80ed620aabdf59d4353cda2efb5ed9b38d193aabbaf9356774bddd12e31b182", - "bytes": 9400, - "lines": 241 + "sha256": "3bd92e66f331ffe4d1d1a49f3c92107553ce749f28aeababbad7b882030405da", + "bytes": 11748, + "lines": 302 }, { "path": "tests/test_employment_separation_postgres.sh", @@ -514,4 +514,4 @@ "lines": 656 } ] -} +} \ No newline at end of file