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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 16454da3d..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. @@ -18,7 +19,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 +38,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. @@ -54,6 +56,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 @@ -67,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. diff --git a/database/migrations/0014_employment_separation_transition.sql b/database/migrations/0014_employment_separation_transition.sql new file mode 100644 index 000000000..2416392ab --- /dev/null +++ b/database/migrations/0014_employment_separation_transition.sql @@ -0,0 +1,537 @@ +-- 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 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 + 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 +) +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_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_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 + 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 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; + 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( + public.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 + 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 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 + 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 + ); + + 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( + public.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, + v_canonical_event_json, + v_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; 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; 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; 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..7fbfa564e --- /dev/null +++ b/database/migrations/0017_assignment_employment_separation_serialization.sql @@ -0,0 +1,116 @@ +-- 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. +-- +-- 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 = 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 +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; +$$; + +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; 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. 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. 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. 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. 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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fd6dd3ea6..2dd5dc597 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, 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. -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. 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; diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 29d11a478..25b64615d 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -4,20 +4,28 @@ | 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. 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. | +| 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. +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. + +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. 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. 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 +``` 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 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..25fa95751 --- /dev/null +++ b/docs/adr/0015-governed-employment-separation-transition.md @@ -0,0 +1,122 @@ +# 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. + +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. +- 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. +- 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 + +### 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. + +### 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. + +### 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. + +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. +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 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. 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 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 `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. +- 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. diff --git a/docs/traceability/employment-separation.md b/docs/traceability/employment-separation.md new file mode 100644 index 000000000..89715163d --- /dev/null +++ b/docs/traceability/employment-separation.md @@ -0,0 +1,63 @@ +# 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 | 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 | +| 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 | +| 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 | +| 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/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 | +| 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`, `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. + +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. + +`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. + +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. + +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. + +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`, `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. 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/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. diff --git a/manifest.json b/manifest.json index f7b6cf55e..7835ab9a4 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 }, @@ -29,9 +29,9 @@ }, { "path": "CHANGELOG.md", - "sha256": "f2d2e0b488c0440533effa821808f2f17e37d92f8fb586174c2fdb594f760ca5", - "bytes": 17539, - "lines": 77 + "sha256": "8687ead43d9e9f2216372e01121542f91e6a27b5f4b736f4ae59743b25000785", + "bytes": 18657, + "lines": 80 }, { "path": "CLAUDE.md", @@ -135,41 +135,65 @@ "bytes": 12713, "lines": 260 }, + { + "path": "database/migrations/0014_employment_separation_transition.sql", + "sha256": "2e5a04671b6b47aa4263121d79c0b297ac9f1325c7e4a236bb55af8408c5ce6b", + "bytes": 21867, + "lines": 537 + }, + { + "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": "database/migrations/0017_assignment_employment_separation_serialization.sql", + "sha256": "115e19bd6fa83d5715ed64b40790543723a5f588b1563b0b6c8ccc1310779848", + "bytes": 4703, + "lines": 116 + }, { "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", - "sha256": "82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62", - "bytes": 11189, - "lines": 71 + "sha256": "5743ac3b19da47acfd08f65f0a9125bff5775fa619bf2022f08dd0e880ce4003", + "bytes": 15247, + "lines": 95 }, { "path": "docs/PRD.md", - "sha256": "3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1", - "bytes": 5490, - "lines": 111 + "sha256": "9a4af723b1d039e11521a2e408ff746eb699b9bfceea9521f5c3636c1b523c96", + "bytes": 8283, + "lines": 124 }, { "path": "docs/SECURITY.md", - "sha256": "01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac", - "bytes": 11185, - "lines": 64 + "sha256": "897e51bd2ee20efe06b7f776729858094f0468f61013503afb0b0d2b71734578", + "bytes": 14041, + "lines": 73 }, { "path": "docs/STORYBOARD.md", @@ -185,15 +209,15 @@ }, { "path": "docs/TEST_STRATEGY.md", - "sha256": "d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8", - "bytes": 16534, - "lines": 135 + "sha256": "c3bd4be0ac6671cc4909a65723c5c846a3bf1c06ea0b0462b387ddfa27be5382", + "bytes": 20692, + "lines": 150 }, { "path": "docs/THREAT_MODEL.md", - "sha256": "f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252", - "bytes": 6736, - "lines": 23 + "sha256": "792e5cca14840ff33e15034fe7af0d412a23785e8b451b3b0f37e90561074352", + "bytes": 11692, + "lines": 31 }, { "path": "docs/TRACEABILITY.md", @@ -203,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,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", @@ -353,15 +377,15 @@ }, { "path": "schemas/openapi.yaml", - "sha256": "09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f", - "bytes": 29503, - "lines": 1020 + "sha256": "533ceb35786c97d8e5d589b6853192837ad420c5be87c33761a22d8399c54f4e", + "bytes": 33818, + "lines": 1149 }, { "path": "scripts/foundation-contract-core.mjs", - "sha256": "9b03efbbdffa60a05f5924e8a61b1cbc3cd75c502df428a5920085e8d0bf3603", - "bytes": 28121, - "lines": 688 + "sha256": "40fd2811107590e72a251a5863d100c0c98d0dea57c0a89b4530ee5caa258a46", + "bytes": 28597, + "lines": 695 }, { "path": "scripts/foundation-contract.mjs", @@ -383,9 +407,15 @@ }, { "path": "tests/openapi-contract.test.mjs", - "sha256": "80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc", - "bytes": 6438, - "lines": 195 + "sha256": "0a8f459d3f058ac70cded5d42db4108e7bb0a065d26b58b84e7b985101f4b06b", + "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", @@ -417,6 +447,18 @@ "bytes": 17811, "lines": 469 }, + { + "path": "tests/test_employment_separation_capability_postgres.sh", + "sha256": "3bd92e66f331ffe4d1d1a49f3c92107553ce749f28aeababbad7b882030405da", + "bytes": 11748, + "lines": 302 + }, + { + "path": "tests/test_employment_separation_postgres.sh", + "sha256": "b70680e69aa8115435601ffa664860723a44fe68cdccf8d3d23197205abc4fce", + "bytes": 21800, + "lines": 561 + }, { "path": "tests/test_evidence_sealing_postgres.sh", "sha256": "57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7", @@ -467,9 +509,9 @@ }, { "path": "tests/validate_repository.py", - "sha256": "091836b2f68600a30b08f7da2cea8b3bef10201a123da720a7369bf10985eec2", - "bytes": 27237, - "lines": 637 + "sha256": "50370ee20344d412682101c1eedb300876f7299fa6b2b5536cb6db38aee1b710", + "bytes": 28197, + "lines": 656 } ] -} +} \ No newline at end of file diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 0fd397e92..f27d04603 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/SeparationConflict' + '413': + $ref: '#/components/responses/PayloadTooLarge' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '500': + $ref: '#/components/responses/InternalError' /position-records: post: operationId: createPositionRecord @@ -569,6 +612,68 @@ 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 + 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._~-]*$' + 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 @@ -641,7 +746,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 @@ -1000,6 +1105,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: @@ -1012,8 +1123,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: diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 4aacefb3c..9f897a18c 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -67,6 +67,10 @@ 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', + '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', @@ -75,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', @@ -88,6 +93,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/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. diff --git a/services/people-api/pyproject.toml b/services/people-api/pyproject.toml index 3f228e11c..af2e018d0 100644 --- a/services/people-api/pyproject.toml +++ b/services/people-api/pyproject.toml @@ -7,11 +7,11 @@ 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 = [ - "orgmetra-hris-kernel==0.1.0", + "orgmetra-hris-kernel==0.4.0", "orgmetra-keyverse-adapter==0.1.0", ] diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index b043bed33..11c2266be 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,15 @@ 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, +) +from orgmetra_people_api.separation_http import EmploymentSeparationAsgiApp __all__ = [ "AuthenticatedPrincipal", @@ -67,10 +76,16 @@ "PostgresHireAcceptancePort", "PostgresPeopleMutationPort", "PostgresPeopleReadPort", + "PostgresEmploymentSeparationPort", "AssignmentMutationCommand", "AssignmentMutationResult", "EmploymentMutationCommand", "EmploymentMutationResult", + "EmploymentSeparationAsgiApp", + "EmploymentSeparationCommand", + "EmploymentSeparationIntegrityError", + "EmploymentSeparationPort", + "EmploymentSeparationResult", "TokenAuthenticator", "WorkerPeopleRecord", "accept_confirmed_hire", @@ -80,4 +95,5 @@ "create_position_record", "extract_bearer_token", "read_worker_people_record", + "separate_employment_record", ] diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 6823f4c59..93d68d131 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 @@ -34,10 +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 a real UUID outside Orgmetra's reserved protocol sentinels.""" - if not isinstance(value, UUID) or value.int in (0, _MAX_UUID_INT): +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) @@ -67,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", @@ -80,10 +84,11 @@ 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 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") @@ -95,7 +100,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.") @@ -110,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 @@ -147,8 +153,14 @@ 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") + 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( + int=command.candidate_worker_conversion_record_id.int + ) if not isinstance(mutation_port, HireAcceptancePort): raise TypeError("mutation_port must implement HireAcceptancePort") @@ -164,6 +176,13 @@ 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") + result = replace(result) + if ( + 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 diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 6baeac684..9628f6c94 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. """ @@ -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"}) @@ -46,124 +47,84 @@ class PeopleMutationIntegrityError(RuntimeError): """Indicate that the mutation cannot persist without violating employment truth.""" -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): +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 _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 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.") 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.") 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 context-independent numeric(5,4) spelling used by persistence.""" - whole, _separator, fraction = format(value, "f").partition(".") + ratio = _validate_allocation_ratio(value) + whole, _separator, fraction = format(ratio, "f").partition(".") return f"{whole}.{fraction:0<4}" -def command_route( - command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, -) -> str: - """Return the durable route that scopes one People mutation idempotency key.""" - if isinstance(command, EmploymentMutationCommand): - return "employment-records" - if isinstance(command, PositionMutationCommand): - return "position-records" - if isinstance(command, AssignmentMutationCommand): - return "assignment-records" - raise TypeError("command must be a governed People mutation command") +def _canonical_uuid(value: object, *, field_name: str) -> str: + """Return a canonical UUID string after exact operational validation.""" + return str(UUID(int=_validate_operational_uuid(field_name, value))) -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.""" - return uuid5( - _IDEMPOTENCY_NAMESPACE, - f"{tenant_record_id}:{command_route_value}:{idempotency_key}", - ) - - -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 not isinstance(authorization, AuthorizationDecision): - raise TypeError("authorization must be an AuthorizationDecision") - if isinstance(command, EmploymentMutationCommand): - 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 isinstance(command, PositionMutationCommand): - 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 isinstance(command, AssignmentMutationCommand): - 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 @@ -179,25 +140,30 @@ class EmploymentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed before authorization or persistence on malformed input.""" - for field_name in ( - "tenant_record_id", - "person_record_id", - "employment_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", - "audit_event_record_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", - ): - _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: - raise ValueError("employment_status_code must be active, leave, or terminated.") - if ( - not isinstance(self.employment_concurrency_code, str) - or self.employment_concurrency_code not in _CONCURRENCY_CODES - ): - raise ValueError("employment_concurrency_code must be exclusive or concurrent.") + _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 + ) + 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) @@ -205,13 +171,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 @@ -221,21 +187,30 @@ class PositionMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed before authorization or persistence on malformed input.""" - for field_name in ( - "tenant_record_id", - "organization_unit_id", - "job_profile_id", - "position_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", - "audit_event_record_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", - ): - _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: - raise ValueError("position_status_code must be a staffable or closed seat status.") + _clone_uuid("outbox_delivery_record_id", self.outbox_delivery_record_id), + ) + _validate_semantic_text("position_status_code", self.position_status_code, _POSITION_STATUSES) + 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) @@ -243,7 +218,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 @@ -259,27 +234,27 @@ class AssignmentMutationCommand: idempotency_key: str def __post_init__(self) -> None: - """Fail closed before authorization or persistence on malformed input.""" - for field_name in ( - "tenant_record_id", - "employment_record_id", - "person_record_id", - "position_record_id", - "assignment_record_id", - "audit_event_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, "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", - ): - _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): - 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.") + _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) @@ -287,40 +262,55 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class EmploymentMutationResult: - """Opaque identity returned after one committed 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: - """Prevent malformed persistence results from 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.") @dataclass(frozen=True, slots=True) class PositionMutationResult: - """Opaque identity returned after one committed 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: - """Prevent malformed persistence results from 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.") @dataclass(frozen=True, slots=True) class AssignmentMutationResult: - """Opaque identity returned after one committed 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: - """Prevent malformed persistence results from 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.") @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, @@ -328,7 +318,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, @@ -336,7 +327,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, @@ -344,7 +336,192 @@ def create_assignment( command: AssignmentMutationCommand, authorization: AuthorizationDecision, ) -> AssignmentMutationResult: - """Persist one assignment or raise without partial writes.""" + """Persist one Assignment mutation.""" + ... + + +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: + """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("command must be a governed People mutation command") + + +def mutation_command_digest( + *, + command: EmploymentMutationCommand | PositionMutationCommand | AssignmentMutationCommand, + authorization: AuthorizationDecision, +) -> str: + """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": 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": route, + "tenant_record_id": _canonical_uuid( + semantic_command.tenant_record_id, field_name="tenant_record_id" + ), + } + if type(semantic_command) is EmploymentMutationCommand: + payload = { + **common, + "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(semantic_command) is PositionMutationCommand: + payload = { + **common, + "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( + semantic_command.organization_unit_id, field_name="organization_unit_id" + ), + "position_status_code": semantic_command.position_status_code, + } + else: + payload = { + **common, + "allocation_ratio": _canonical_allocation_ratio(semantic_command.allocation_ratio), + "effective_from": semantic_command.effective_from.isoformat(), + "employment_record_id": _canonical_uuid( + 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" + ), + "position_record_id": _canonical_uuid( + semantic_command.position_record_id, field_name="position_record_id" + ), + } + 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_port(mutation_port: object) -> PeopleMutationPort: @@ -354,6 +531,88 @@ def _require_port(mutation_port: object) -> PeopleMutationPort: return mutation_port +def _authorize_mutation( + *, + principal: AuthenticatedPrincipal, + tenant_record_id: UUID, + purpose_code: str, + resource_kind: str, + resource_id: UUID, + requested_fields: frozenset[str], + policy: PurposeBoundAccessPolicy, +) -> AuthorizationDecision: + """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, + requested_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, @@ -362,24 +621,41 @@ def create_employment_record( policy: PurposeBoundAccessPolicy, mutation_port: PeopleMutationPort, ) -> EmploymentMutationResult: - """Authorize the exact employment target before persisting worker employment truth.""" - if not isinstance(command, EmploymentMutationCommand): + """Authorize and persist one governed Employment record/version.""" + if type(command) is not EmploymentMutationCommand: raise TypeError("command must be an EmploymentMutationCommand") port = _require_port(mutation_port) - authorization = authorize_resource_fields( + 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, - resource_tenant_record_id=command.tenant_record_id, - resource_reference=f"employment_record:{command.employment_record_id.hex}", + tenant_record_id=semantic_command.tenant_record_id, purpose_code=purpose_code, - operation_code="create_record", resource_kind="employment_record", + resource_id=semantic_command.employment_record_id, requested_fields=_EMPLOYMENT_FIELDS, policy=policy, ) - result = port.create_employment(command=command, authorization=authorization) - if not isinstance(result, EmploymentMutationResult): - raise TypeError("mutation_port must return EmploymentMutationResult") + 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 @@ -391,24 +667,39 @@ def create_position_record( policy: PurposeBoundAccessPolicy, mutation_port: PeopleMutationPort, ) -> PositionMutationResult: - """Authorize the exact position target before persisting a staffable seat.""" - if not isinstance(command, PositionMutationCommand): + """Authorize and persist one governed Position record/version.""" + if type(command) is not PositionMutationCommand: raise TypeError("command must be a PositionMutationCommand") port = _require_port(mutation_port) - authorization = authorize_resource_fields( + 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, - resource_tenant_record_id=command.tenant_record_id, - resource_reference=f"position_record:{command.position_record_id.hex}", + tenant_record_id=semantic_command.tenant_record_id, purpose_code=purpose_code, - operation_code="create_record", resource_kind="position_record", + resource_id=semantic_command.position_record_id, requested_fields=_POSITION_FIELDS, policy=policy, ) - result = port.create_position(command=command, authorization=authorization) - if not isinstance(result, PositionMutationResult): - raise TypeError("mutation_port must return PositionMutationResult") + 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 @@ -420,29 +711,49 @@ def create_assignment_record( policy: PurposeBoundAccessPolicy, mutation_port: PeopleMutationPort, ) -> AssignmentMutationResult: - """Authorize the exact assignment target before persisting seat allocation.""" - if not isinstance(command, AssignmentMutationCommand): + """Authorize and persist one governed Assignment record.""" + if type(command) is not AssignmentMutationCommand: raise TypeError("command must be an AssignmentMutationCommand") port = _require_port(mutation_port) - authorization = authorize_resource_fields( + 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, - resource_tenant_record_id=command.tenant_record_id, - resource_reference=f"assignment_record:{command.assignment_record_id.hex}", + tenant_record_id=semantic_command.tenant_record_id, purpose_code=purpose_code, - operation_code="create_record", resource_kind="assignment_record", + resource_id=semantic_command.assignment_record_id, requested_fields=_ASSIGNMENT_FIELDS, policy=policy, ) - result = port.create_assignment(command=command, authorization=authorization) - if not isinstance(result, AssignmentMutationResult): - raise TypeError("mutation_port must return AssignmentMutationResult") + 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 four-decimal ratio.""" - if not isinstance(raw_value, str) or re.fullmatch(r"^(0\.[0-9]{4}|1\.0000)$", raw_value) is None: + """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 Decimal(raw_value) + return ratio 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..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,13 +11,14 @@ from __future__ import annotations from contextlib import AbstractContextManager -from dataclasses import dataclass -from datetime import datetime +from dataclasses import replace +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 @@ -164,19 +165,38 @@ 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 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 0 < identity < _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(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}" - if not isinstance(authorization, AuthorizationDecision): + if type(authorization) is not AuthorizationDecision: raise HireDecisionIntegrityError("hire mutation requires a typed authorization decision") if ( not authorization.allowed @@ -227,13 +247,17 @@ 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 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") @@ -271,22 +295,30 @@ 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. 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,)) def accept_hire( self, @@ -304,11 +336,13 @@ 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") + command = replace(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),)) @@ -323,14 +357,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, @@ -339,18 +374,25 @@ def accept_hire( decided_at, evidence_set_id, transaction_recorded_at, - ) = row - + ) = rows[0] + + 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") 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..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,19 +1,22 @@ """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; 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 from contextlib import AbstractContextManager -from dataclasses import dataclass -from datetime import date, datetime +from dataclasses import replace +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, @@ -53,16 +56,27 @@ _POSITION_FIELDS = frozenset({"position_record"}) _ASSIGNMENT_FIELDS = frozenset({"assignment_record"}) -_CONVERSION_SQL = """ +_PERSON_EMPLOYMENT_ANCHOR_SQL = """ SELECT - conversion.candidate_worker_conversion_record_id, + person.person_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 +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 conversion +FOR UPDATE OF person +""".strip() + +_ASSIGNMENT_EMPLOYMENT_ANCHOR_SQL = """ +SELECT + 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 employment """.strip() _EMPLOYMENT_VERSIONS_SQL = """ @@ -252,13 +266,37 @@ 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 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 0 < identity < _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( + 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( @@ -266,25 +304,29 @@ 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) 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): + 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") assert isinstance(created_record_id, UUID) - return created_record_id + return created_record_id, stored_digest def _record_idempotency( @@ -322,7 +364,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 @@ -378,8 +420,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) @@ -419,7 +461,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) @@ -460,7 +502,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) @@ -485,47 +527,93 @@ def _assignment_from_row(tenant_record_id: UUID, row: tuple[object, ...]) -> Ass ) -def _require_one_conversion(rows: list[tuple[object, ...]]) -> tuple[UUID, datetime]: - """Require exactly one current conversion row and a usable transaction timestamp.""" - if not rows: - raise PeopleMutationIntegrityError("person has no governed candidate-worker conversion") - if len(rows) != 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] - 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 +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_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="assignment employment anchor row has an invalid shape", + ) + if not detached: + raise PeopleMutationNotFound("assignment employment record was not found") + if len(detached) != 1: + 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: """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) 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. + 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,)) def create_employment( self, @@ -533,9 +621,10 @@ def create_employment( command: EmploymentMutationCommand, authorization: AuthorizationDecision, ) -> EmploymentMutationResult: - """Persist one employment after conversion and exclusivity checks.""" - if not isinstance(command, EmploymentMutationCommand): + """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) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, @@ -543,23 +632,39 @@ 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),)) replayed = _replayed_record_id(cursor, command=command, authorization=decision) if replayed is not None: - return EmploymentMutationResult(employment_record_id=replayed) - cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) - _require_one_conversion(cursor.fetchmany(2)) + replayed_record_id, replay_digest = replayed + return EmploymentMutationResult( + employment_record_id=replayed_record_id, + replay_command_digest=replay_digest, + ) + 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, (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, @@ -637,8 +742,9 @@ 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") + command = replace(command) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, @@ -646,25 +752,36 @@ 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),)) 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), ) - 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 ( - 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) ): @@ -726,9 +843,10 @@ def create_assignment( command: AssignmentMutationCommand, authorization: AuthorizationDecision, ) -> AssignmentMutationResult: - """Persist one assignment after conversion and kernel coverage checks.""" - if not isinstance(command, AssignmentMutationCommand): + """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) decision = _require_authorization( authorization=authorization, tenant_record_id=command.tenant_record_id, @@ -736,30 +854,52 @@ 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),)) replayed = _replayed_record_id(cursor, command=command, authorization=decision) if replayed is not None: - return AssignmentMutationResult(assignment_record_id=replayed) - cursor.execute(_CONVERSION_SQL, (command.tenant_record_id, command.person_record_id)) - _require_one_conversion(cursor.fetchmany(2)) + replayed_record_id, replay_digest = replayed + return AssignmentMutationResult( + assignment_record_id=replayed_record_id, + replay_command_digest=replay_digest, + ) + 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), ) + 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 +910,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, 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..f94e3509b --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/postgres_separation.py @@ -0,0 +1,176 @@ +"""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, NoReturn + +from orgmetra_keyverse_adapter import AuthorizationDecision + +from orgmetra_people_api.mutations import PeopleMutationNotFound +from orgmetra_people_api.separation import ( + EmploymentSeparationCommand, + EmploymentSeparationIntegrityError, + EmploymentSeparationPersistenceIntegrityError, + 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 EmploymentSeparationPersistenceIntegrityError( + "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 EmploymentSeparationPersistenceIntegrityError( + "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 EmploymentSeparationPersistenceIntegrityError( + "Employment separation database result is invalid" + ) + row = rows[0] + if type(row) not in (list, tuple) or len(row) != 4: + raise EmploymentSeparationPersistenceIntegrityError( + "Employment separation database result is invalid" + ) + 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 EmploymentSeparationPersistenceIntegrityError( + "Employment separation database result is invalid" + ) from error + if result.employment_record_id != expected_employment_record_id: + raise EmploymentSeparationPersistenceIntegrityError( + "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) + 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) + result = _validated_result( + row=row, + expected_employment_record_id=detached_command.employment_record_id, + ) + except Exception as error: + _translate_database_error(error) + + return result 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..b20250a37 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/separation.py @@ -0,0 +1,210 @@ +"""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]+)*$") +_ALLOWED_SEPARATION_REASON_CODES = frozenset( + { + "voluntary_resignation", + "retirement_transition", + "fixed_term_completion", + "position_elimination", + "employer_initiated_separation", + } +) +_EMPLOYMENT_FIELDS = frozenset({"employment_record"}) + + +class EmploymentSeparationIntegrityError(RuntimeError): + """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: + """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.") + if not (0 < value.int < _MAX_UUID_INT): + raise ValueError(f"{field_name} must be an operational UUID.") + 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: + 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: + 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 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): + 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: + 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) + 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: + 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) + 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 EmploymentSeparationPersistenceIntegrityError("separation result identity does not match command") + return detached_result 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..ef6b70b65 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/separation_http.py @@ -0,0 +1,378 @@ +"""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, + EmploymentSeparationPersistenceIntegrityError, + 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 _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: + 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], + 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: + 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=audit_event_record_id, + outbox_delivery_record_id=outbox_delivery_record_id, + ) + + +@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) + 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): + 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, + 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 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, + 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, + }, + ) 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..b60937f6f --- /dev/null +++ b/services/people-api/tests/test_assignment_separation_anchor_lock_regression.py @@ -0,0 +1,27 @@ +"""Regression contract for Assignment versus Employment-separation serialization.""" + +from __future__ import annotations + +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_MIGRATION = _REPO_ROOT / "database/migrations/0017_assignment_employment_separation_serialization.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 "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 + 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 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..27d7bb3d6 --- /dev/null +++ b/services/people-api/tests/test_conversion_employment_binding_regression.py @@ -0,0 +1,40 @@ +"""Regression contracts for candidate-conversion versus People mutation authority.""" + +from __future__ import annotations + +import inspect +import unittest + +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): + """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"): + _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() \ No newline at end of file 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..d2210eb21 --- /dev/null +++ b/services/people-api/tests/test_employment_separation_application.py @@ -0,0 +1,276 @@ +"""Executable application contracts for governed Employment separation.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone, tzinfo +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 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.""" + + 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 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=submitted_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, 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") + + 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: + 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), + ) + 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="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, + 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="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, + 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] + ) + 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 + unittest.main() 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..9f73479f6 --- /dev/null +++ b/services/people-api/tests/test_employment_separation_http.py @@ -0,0 +1,391 @@ +"""Executable HTTP contracts for governed Employment separation.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import date, datetime, timezone +import json +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import ( + AuthorizationDecision, + 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 +from orgmetra_people_api.mutations import PeopleMutationNotFound + +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: object, *, error: Exception | None = None) -> None: + self.principal = principal + self.error = error + self.tokens: list[str] = [] + + async def authenticate(self, bearer_token: str) -> object: + 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, + 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=id_factory if id_factory is not None else 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()) + 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) + 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_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) + cases = ( + ({"method": "GET"}, 405), + ({"path": "/v1/unknown"}, 404), + ({"headers": valid_headers(purpose=b"benefits_admin")}, 403), + ) + 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, []) + + 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")) + ) + 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") + + 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: + 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_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") + + 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() + + 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, []) + + 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() 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..d535195b0 --- /dev/null +++ b/services/people-api/tests/test_employment_separation_integrity_http.py @@ -0,0 +1,139 @@ +"""HTTP regression for internal Employment separation receipt corruption.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +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() 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..74de6a7f2 --- /dev/null +++ b/services/people-api/tests/test_employment_separation_openapi_contract.py @@ -0,0 +1,114 @@ +"""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" +_APPROVED_SEPARATION_REASONS = ( + "voluntary_resignation", + "retirement_transition", + "fixed_term_completion", + "position_elimination", + "employer_initiated_separation", +) + + +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) + 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) + + 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() 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() 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..39b007af6 --- /dev/null +++ b/services/people-api/tests/test_hire_authorization_command_snapshot.py @@ -0,0 +1,152 @@ +"""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") +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") +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 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.""" + 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=UUID(int=CANDIDATE.int), + 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 state 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.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) + self.assertEqual(result.candidate_worker_conversion_record_id, CONVERSION) + + +if __name__ == "__main__": + unittest.main() 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..1b3a44c23 --- /dev/null +++ b/services/people-api/tests/test_hire_identity_runtime_integrity.py @@ -0,0 +1,185 @@ +"""Runtime identity-integrity regressions for confirmed-hire contracts.""" + +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, +) + +TENANT = UUID("0198a412-7000-7000-8000-000000000001") + + +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" + + +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": 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"), + "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 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( + "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"), + ) + + +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(), + ) 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..53b5a0620 --- /dev/null +++ b/services/people-api/tests/test_hire_post_construction_integrity.py @@ -0,0 +1,246 @@ +"""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 AuthorizationDecision, 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") +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") +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 AttributeError("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 + + +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( + 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 _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__( + 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] + + +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),) 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..67d497360 --- /dev/null +++ b/services/people-api/tests/test_internal_dependency_versions.py @@ -0,0 +1,49 @@ +"""Keep People API package metadata aligned with canonical owned package versions.""" + +from pathlib import Path +import re +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 _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") + 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 + + +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 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..f2f238356 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_allocation_text_runtime_integrity.py @@ -0,0 +1,47 @@ +"""Reject non-canonical allocation-ratio text before Decimal parsing.""" + +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 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..4ef969eec --- /dev/null +++ b/services/people-api/tests/test_people_mutation_authorization_command_snapshot.py @@ -0,0 +1,360 @@ +"""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, + mutate_nested_uuid: bool = False, + ) -> _MutatingResourceKind: + """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: + """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, + mutate_nested_uuid: bool = False, +) -> 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, + mutate_nested_uuid=mutate_nested_uuid, + ), + 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_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() + 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_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() + 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) + + 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() 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) 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() 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), + ) 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..9ad2f02cb --- /dev/null +++ b/services/people-api/tests/test_people_mutation_replay_semantic_snapshot.py @@ -0,0 +1,175 @@ +"""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 _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.""" + + 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(), + ) + + 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() 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() 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() 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() 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] + ) 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) 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..2b0bad992 --- /dev/null +++ b/services/people-api/tests/test_people_mutation_text_runtime_integrity.py @@ -0,0 +1,200 @@ +"""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") + + +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] = { + "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")) + 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: + """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")) + + +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")) 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..a0dedcfb0 --- /dev/null +++ b/services/people-api/tests/test_postgres_assignment_concurrency_acceptance.py @@ -0,0 +1,807 @@ +"""Real PostgreSQL interleavings for governed Assignment conflict domains. + +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 + +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 + +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: Exception | 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 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] + 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', " + "'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}', " + 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 Exception 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 _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 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 == "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" + + +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() + 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() + _join_writer_before_teardown(database_url, thread=first, factory=first_factory) + if second.ident is not None: + _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 + 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, + ) 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..7032fb49a --- /dev/null +++ b/services/people-api/tests/test_postgres_assignment_concurrency_failure_cleanup.py @@ -0,0 +1,243 @@ +"""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 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"] + 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.""" + + 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: + 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__["threading"] = _ThreadingProbe + 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") + + assert join_timeouts == [30, 30], "concurrency writer cleanup joins must be deadline-bounded" + 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 " + "WHERE application_name LIKE 'orgmetra-assignment-concurrency-writer-%';", + ) + assert active_writers == "0" + finally: + 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 _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._expire_first_join and self._join_calls == 1: + super().join(timeout=0) + return + super().join(timeout=timeout) + + class _ThreadingProbe: + Event = original_threading.Event + Thread = _ExpireHeldWriterJoinThread + + 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: + result = original_psql(database_url, sql) + if "pg_terminate_backend" in sql: + termination_sql.append(sql) + hold_second_exit.set() + return result + + 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 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..a40dae5b6 --- /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}', 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}', 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-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) + 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}', 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-08-17', pg_catalog.clock_timestamp() - INTERVAL '5 minutes'); + """, + ) + 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" 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..afbcbb90c --- /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) + with self.assertRaises((AttributeError, TypeError)): + object.__setattr__(port, "connection_factory", replacement_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"]) + + 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() 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..8f0ea66f3 --- /dev/null +++ b/services/people-api/tests/test_postgres_employment_separation.py @@ -0,0 +1,259 @@ +"""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, + EmploymentSeparationPersistenceIntegrityError, +) + +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: 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) -> 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 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: + 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)) + 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) + 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: 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"), + 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(EmploymentSeparationPersistenceIntegrityError): + port.separate_employment(command=command(), authorization=decision) # type: ignore[arg-type] + + def test_rejects_malformed_database_result_before_transaction_commit_boundary(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: + connection = FakeConnection(FakeCursor(row=row)) + 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, EmploymentSeparationPersistenceIntegrityError) + + connection = FakeConnection(InvalidBatchCursor()) + with self.assertRaises(EmploymentSeparationPersistenceIntegrityError): + port = PostgresEmploymentSeparationPort(ConnectionFactory(connection)) + port.separate_employment(command=command(), authorization=authorization()) + self.assertIs(connection.exit_exception_type, EmploymentSeparationPersistenceIntegrityError) + + 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_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) + 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() 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), + ) 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 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) 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..a58ebfca0 --- /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 TypeError("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 IndexError("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 IndexError("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) 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..15f62ff86 --- /dev/null +++ b/services/people-api/tests/test_postgres_hire_timestamp_runtime_integrity.py @@ -0,0 +1,53 @@ +"""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: + """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_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 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..be18c536c --- /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 AttributeError("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 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, + ) 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), + ) 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 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..ec98d34f1 --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_post_construction_integrity.py @@ -0,0 +1,245 @@ +"""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 ( + AssignmentMutationCommand, + EmploymentMutationCommand, + 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") +ASSIGNMENT = UUID("0198a412-a700-7000-8000-000000000070") +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(*, 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"{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 rewritten command evidence reaches PostgreSQL transaction work.""" + raise AssertionError("database work must not begin for a rewritten mutation command") + + +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, + 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-employment", + evidence_version_code="employment-evidence-v1", + idempotency_key="post-construction-runtime-227-employment", + ) + 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(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), + ) + + +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 + + +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 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"), ()) 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..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, @@ -38,7 +37,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 +51,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 +65,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,18 +81,18 @@ 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: - """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) 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..11545ca7c --- /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 TypeError("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 IndexError("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() 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..5de9cbb1d --- /dev/null +++ b/services/people-api/tests/test_postgres_mutation_scalar_runtime_integrity.py @@ -0,0 +1,307 @@ +"""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, + _position_version_from_row, + _replayed_record_id, +) +from test_people_mutations import employment_command +from test_postgres_people_mutations import employment_authorization + + +_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 AttributeError("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") + + +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 _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 + + __hash__ = None + + 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 _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 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 TypeError("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 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 TypeError("Decimal subtype reverse addition 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") + + 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 + + +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 + + +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 + + +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 diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index 0175fc6d4..2793e6161 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) @@ -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,27 +280,28 @@ 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) 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,29 @@ 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_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([[(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 +355,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, @@ -374,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, @@ -385,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, @@ -396,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): @@ -409,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): @@ -427,7 +449,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 +492,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, @@ -481,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"): @@ -504,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"): @@ -594,7 +616,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) 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") 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..0e2fe94bb --- /dev/null +++ b/services/people-api/tests/test_uuid_payload_integrity.py @@ -0,0 +1,84 @@ +"""Reject forged exact UUID payloads before scalar use in People mutation boundaries.""" + +from __future__ import annotations + +import unittest +from uuid import 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.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") +_OUT_OF_RANGE_IDENTITIES = (-1, 1 << 128) + + +class _ExecutableUUIDPayload: + """Tripwire that exposes scalar 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 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): + with self.subTest(validator=validator.__module__): + payload = _ExecutableUUIDPayload() + + with self.assertRaisesRegex(ValueError, "tenant_record_id must be an operational UUID"): + validator("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_application_uuid_gates_reject_out_of_range_exact_integer_payload(self) -> None: + 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"): + 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): + 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: + 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)) + self.assertTrue(_is_mutation_operational_uuid(_OPERATIONAL_UUID)) + + +if __name__ == "__main__": + unittest.main() 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/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" diff --git a/tests/test_employment_separation_capability_postgres.sh b/tests/test_employment_separation_capability_postgres.sh new file mode 100644 index 000000000..c3895c34f --- /dev/null +++ b/tests/test_employment_separation_capability_postgres.sh @@ -0,0 +1,302 @@ +#!/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 \ + database/migrations/0016_employment_separation_executor_capability.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 + +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 + +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 +JOIN pg_catalog.pg_roles AS owner_role + ON owner_role.oid = function_record.proowner +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 -Atqc " +SELECT pg_catalog.has_function_privilege( + 'orgmetra_employment_separation_executor', + 'public.separate_employment_record_once(uuid,uuid,uuid,uuid,date,text,text,text,text,text,text,text,uuid,uuid)', + '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 +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; +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 + +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 + NOCREATEROLE + NOINHERIT + NOBYPASSRLS; +SQL +probe_role_created=true + +set +e +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( + '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 + +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 + 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" 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..a70df00c0 --- /dev/null +++ b/tests/test_employment_separation_distinct_key_concurrency.sh @@ -0,0 +1,268 @@ +#!/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 +); +SQL + +first_ready=false +for _ in $(seq 1 100); do + 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 + 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 observable pre-commit ClientRead 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" diff --git a/tests/test_employment_separation_failure_cleanup.sh b/tests/test_employment_separation_failure_cleanup.sh new file mode 100644 index 000000000..5f6b19d5f --- /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 [[ ${second_status} -eq 0 ]]; then + echo "blocked 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" diff --git a/tests/test_employment_separation_postgres.sh b/tests/test_employment_separation_postgres.sh new file mode 100644 index 000000000..fccbdaef3 --- /dev/null +++ b/tests/test_employment_separation_postgres.sh @@ -0,0 +1,561 @@ +#!/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 + +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 + ('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-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 + +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() { + 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" 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" diff --git a/tests/validate_repository.py b/tests/validate_repository.py index d9d4c15a3..a4e5f6fb6 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -70,6 +70,10 @@ "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", + "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 +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", @@ -91,6 +96,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", ] @@ -226,12 +233,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", @@ -379,7 +393,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: + 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: