From 14cafe509e7bddb748afc524c5b564643e1df47d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 13:03:16 +0900 Subject: [PATCH 1/2] test(api): cover v2 employer contract --- .../people-api/tests/test_api_versioning.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 services/people-api/tests/test_api_versioning.py diff --git a/services/people-api/tests/test_api_versioning.py b/services/people-api/tests/test_api_versioning.py new file mode 100644 index 000000000..3820c51da --- /dev/null +++ b/services/people-api/tests/test_api_versioning.py @@ -0,0 +1,126 @@ +"""Regression contracts for versioning newly required employing-organization facts.""" + +from __future__ import annotations + +from uuid import UUID + +import pytest + +from orgmetra_people_api.hire_http import ( + _command_from_payload, + _looks_like_hire_route, + _parse_hire_route, +) +from orgmetra_people_api.mutation_http import _command_for_route, _mutation_route + +TENANT = UUID("0198a412-8200-7000-8000-000000000001") +PERSON = UUID("0198a412-8200-7000-8000-000000000010") +ORGANIZATION = UUID("0198a412-8200-7000-8000-000000000020") +IDEMPOTENCY_KEY = "api-versioning-key-17" + + +def _employment_payload(*, status: str, include_employer: bool) -> dict[str, object]: + """Build one bounded employment payload for the V1/V2 parser contract.""" + payload: dict[str, object] = { + "person_record_id": str(PERSON), + "employment_status_code": status, + "employment_concurrency_code": "exclusive", + "effective_from": "2026-08-18", + "decision_reason": "Record the governed employment fact.", + "confirmation_reference": "human_confirmation:api-versioning", + "evidence_references": [ + {"evidence_reference": "decision:17", "evidence_version_code": "v1"} + ], + } + if include_employer: + payload["employing_organization_unit_id"] = str(ORGANIZATION) + return payload + + +def _hire_payload(*, status: str, include_employer: bool) -> dict[str, object]: + """Build one bounded confirmed-hire payload for the V1/V2 parser contract.""" + payload: dict[str, object] = { + "candidate_profile_id": str(UUID("0198a412-8200-7000-8000-000000000030")), + "selection_decision_id": str(UUID("0198a412-8200-7000-8000-000000000031")), + "person_record_id": str(PERSON), + "person_name_record_id": str(UUID("0198a412-8200-7000-8000-000000000032")), + "employment_record_id": str(UUID("0198a412-8200-7000-8000-000000000033")), + "employment_record_version_id": str(UUID("0198a412-8200-7000-8000-000000000034")), + "candidate_worker_conversion_record_id": str(UUID("0198a412-8200-7000-8000-000000000035")), + "audit_event_record_id": str(UUID("0198a412-8200-7000-8000-000000000036")), + "outbox_delivery_record_id": str(UUID("0198a412-8200-7000-8000-000000000037")), + "effective_from": "2026-08-18", + "display_name": "Anonymous Worker", + "employment_status_code": status, + } + if include_employer: + payload["employing_organization_unit_id"] = str(ORGANIZATION) + payload["employment_employing_organization_record_id"] = str( + UUID("0198a412-8200-7000-8000-000000000038") + ) + return payload + + +def test_employment_v1_preserves_old_payload_and_v2_requires_employer() -> None: + """Keep terminated V1 parsing while requiring employer facts in V2 active writes.""" + v1_command = _command_for_route( + "employment-records", + TENANT, + _employment_payload(status="terminated", include_employer=False), + lambda: UUID("0198a412-8200-7000-8000-000000000040"), + IDEMPOTENCY_KEY, + ) + assert v1_command.employing_organization_unit_id is None + + v2_command = _command_for_route( + "employment-records-v2", + TENANT, + _employment_payload(status="active", include_employer=True), + lambda: UUID("0198a412-8200-7000-8000-000000000041"), + IDEMPOTENCY_KEY, + ) + assert v2_command.employing_organization_unit_id == ORGANIZATION + + +def test_versioned_mutation_routes_keep_v1_siblings_unchanged() -> None: + """Expose only Employment under V2 while retaining the existing V1 siblings.""" + assert _mutation_route("/v1/employment-records") == "employment-records" + assert _mutation_route("/v2/employment-records") == "employment-records-v2" + assert _mutation_route("/v2/position-records") is None + assert _mutation_route("/v2/assignment-records") is None + + +def test_hire_v1_preserves_old_payload_and_v2_requires_employer() -> None: + """Keep old terminated hire parsing while requiring employer facts in V2.""" + path = f"/v1/tenants/{TENANT}/candidate-worker-conversions" + v1_tenant, v1_purpose, v1_version = _parse_hire_route(path, b"purpose=candidate_hire") + assert (v1_tenant, v1_purpose, v1_version) == (TENANT, "candidate_hire", "v1") + v1_command = _command_from_payload( + TENANT, + _hire_payload(status="terminated", include_employer=False), + IDEMPOTENCY_KEY, + api_version="v1", + ) + assert v1_command.employing_organization_unit_id is None + + v2_path = f"/v2/tenants/{TENANT}/candidate-worker-conversions" + assert _looks_like_hire_route(v2_path) + v2_command = _command_from_payload( + TENANT, + _hire_payload(status="active", include_employer=True), + IDEMPOTENCY_KEY, + api_version="v2", + ) + assert v2_command.employing_organization_unit_id == ORGANIZATION + + +def test_active_v1_payload_without_employer_is_rejected_with_migration_guidance() -> None: + """Do not let the new database invariant become an opaque V1 persistence failure.""" + with pytest.raises(ValueError, match="v2"): + _command_for_route( + "employment-records", + TENANT, + _employment_payload(status="active", include_employer=False), + lambda: UUID("0198a412-8200-7000-8000-000000000042"), + IDEMPOTENCY_KEY, + ) From 3a904f63e96336e4ae8b52753dd91556d2c6d6c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 13:30:48 +0900 Subject: [PATCH 2/2] fix(api): version employer-required commands --- CHANGELOG.md | 1 + README.md | 2 +- docs/API_CONTRACT.md | 13 ++- docs/SECURITY.md | 2 +- docs/TEST_STRATEGY.md | 1 + docs/TRACEABILITY.md | 2 +- .../0141-employment-employing-organization.md | 2 + .../employment-employing-organization.md | 2 + manifest.json | 2 +- schemas/openapi.yaml | 39 +++++++++ services/people-api/README.md | 4 +- .../src/orgmetra_people_api/hire.py | 25 ++++-- .../src/orgmetra_people_api/hire_http.py | 82 +++++++++++++++---- .../src/orgmetra_people_api/mutation_http.py | 58 +++++++++---- .../src/orgmetra_people_api/mutations.py | 31 +++++-- .../src/orgmetra_people_api/postgres_hire.py | 8 +- .../orgmetra_people_api/postgres_mutations.py | 8 +- .../people-api/tests/test_api_versioning.py | 48 +++++++++++ .../tests/test_decision_reason_binding.py | 2 +- ...t_evidence_reference_binding_regression.py | 2 +- .../people-api/tests/test_hire_http_route.py | 2 +- ...test_mutation_http_authentication_order.py | 4 +- .../tests/test_mutation_http_route.py | 4 +- .../tests/test_mutation_http_schema_types.py | 8 +- .../test_support_reference_correlation.py | 4 +- ...test_support_reference_response_privacy.py | 4 +- tests/openapi-contract.test.mjs | 15 +++- tests/validate_repository.py | 1 + 28 files changed, 304 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e75be9ae..a1fe9cf35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Orgmetra will be documented in this file. ### Added +- Versioned the new employing-organization command boundary: legacy `/v1` terminated payloads remain readable, active/leave V1 payloads without employer facts fail with migration guidance, and employer-required Employment and confirmed-hire writes use `/v2` without weakening the exact-one legal-employer invariant. - Active-PR employment employing-organization truth: bitemporal, tenant-qualified `employment_employing_organization_record` keeps legal-employer identity independent from Position and Assignment, requires exactly one legal employer for every active/leave Employment coordinate without effective gaps, requires `legal_entity` organization classification and active/leave Employment coverage, persists the relationship from both People employment and confirmed-hire transactions, preserves correction history, and proves forced-RLS isolation. ADR-0141 records the bounded HRIS slice; payroll, statutory-account, compensation, and autonomous employment decisions remain out of scope. - 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. diff --git a/README.md b/README.md index 47bb087a3..3f42313ba 100644 --- a/README.md +++ b/README.md @@ -78,4 +78,4 @@ Job evidence ## Status -Protected `develop` includes the employment-truth kernel, governed candidate-to-worker conversion, purpose-bound PII authorization, normalized worker-bound validity studies, criterion-observation scope, bitemporal workforce-composition evidence, the governed Naruon intent adapter, and requisition review packets. This active PR adds durable purpose-bound People mutation and confirmed-hire materialization paths for Employment, Position, and Assignment with atomic audit/outbox evidence and tenant-scoped idempotency; treat those write paths as active-PR truth until this exact head passes all fresh protected-base gates and merges. +Protected `develop` includes the employment-truth kernel, governed candidate-to-worker conversion, purpose-bound PII authorization, normalized worker-bound validity studies, criterion-observation scope, bitemporal workforce-composition evidence, the governed Naruon intent adapter, and requisition review packets. This active PR adds durable purpose-bound People mutation and confirmed-hire materialization paths for Employment, Position, and Assignment with atomic audit/outbox evidence and tenant-scoped idempotency; employer-required Employment and confirmed-hire writes use `/v2`, while legacy `/v1` terminated payloads remain readable and missing-employer active/leave payloads fail with migration guidance. Treat those write paths as active-PR truth until this exact head passes all fresh protected-base gates and merges. diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 1dcd127f7..c05139004 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -2,7 +2,12 @@ ## Versioning -Orgmetra APIs use OpenAPI 3.2.0. Major versions are path-scoped under `/v1` until a breaking contract requires `/v2`. +Orgmetra APIs use OpenAPI 3.2.0. Major versions are path-scoped. The existing +`/v1` Employment and confirmed-hire routes retain their former wire shape so +legacy terminated writes remain readable; an `/v1` active or leave write that +lacks an employing organization is rejected with migration guidance. The +employer-required contracts are available under `/v2` and use +`CreateEmploymentRecordCommandV2` for Employment. ## Authentication @@ -35,7 +40,7 @@ Every mutating request requires: - resource-scoped authorization; and - a command digest stored with the idempotency record. -Employment, position, assignment, person, job-profile, and selection-decision commands carry tenant, actor, and purpose through the reusable `X-Tenant-Reference`, `X-Actor-Reference`, and `X-Purpose-Code` components. The confirmed-hire route instead binds tenant in `/v1/tenants/{tenant_record_id}/candidate-worker-conversions`, purpose in the required query parameter, and actor through the authenticated principal; those path/query/authentication bindings are authoritative for that route and are not duplicated as weaker caller-controlled headers. +Employment, position, assignment, person, job-profile, and selection-decision commands carry tenant, actor, and purpose through the reusable `X-Tenant-Reference`, `X-Actor-Reference`, and `X-Purpose-Code` components. The confirmed-hire route instead binds tenant in `/v1` or `/v2` `/tenants/{tenant_record_id}/candidate-worker-conversions`, purpose in the required query parameter, and actor through the authenticated principal; those path/query/authentication bindings are authoritative for that route and are not duplicated as weaker caller-controlled headers. High-impact commands additionally require: @@ -47,7 +52,7 @@ 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. -Employment creation requires `employing_organization_unit_id` and atomically records the bitemporal employing-organization relationship for active and leave Employment versions. Confirmed-hire materialization requires the employing organization and relationship record identities in its explicit command, and persists that relationship in the same transaction as the Person, Employment, conversion, and audit/outbox evidence. +The `/v2` Employment and confirmed-hire commands require `employing_organization_unit_id` and atomically record the bitemporal employing-organization relationship for active and leave Employment versions. The `/v1` commands preserve the former terminated payload; they do not weaken the database invariant, so active and leave payloads without employer facts fail before persistence with a `/v2` migration action. Confirmed-hire materialization persists the employer relationship in the same transaction as the Person, Employment, conversion, and audit/outbox evidence. 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. @@ -58,6 +63,8 @@ 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 /v2/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire +POST /v2/employment-records POST /v1/position-records POST /v1/assignment-records POST /v1/job-profiles diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 522f0308b..f36e0a565 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -41,7 +41,7 @@ Authorization evidence contains only governance metadata, including the opaque a ## Mutation security contract -Every mutating HTTP operation and its server-side command handler requires one validated `Idempotency-Key` that crosses the command boundary into durable transactional replay state. The published OpenAPI employment, position, assignment, person, job-profile, 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. 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 binds the tenant in `/v1` or `/v2` `/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. Legacy `/v1` active or leave payloads without employer facts are rejected before persistence; `/v2` requires the employer target and its relationship identity. 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. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index c20813b72..0638b9790 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -50,6 +50,7 @@ Required negative and provenance tests include: - 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; +- legacy `/v1` terminated Employment and confirmed-hire payloads remain parseable, while employer-required active/leave commands use `/v2` and V1 missing-employer requests fail before persistence with migration guidance; - concurrent exact-key requests serialize at the persistence boundary and cannot commit two different identities; - 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; diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 021288d05..335e45904 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,7 +15,7 @@ | Evidence-backed human selection decisions | Talent Acquisition | `decision_evidence_set`, `selection_decision_evidence`, `selection_decision` | database-owned SHA-256 sealing, non-empty evidence, drift/reuse rejection, OpenAPI human-confirmation tests | ADR-0001 | implemented_on_active_pr | | Governed candidate-to-worker conversion | Talent Acquisition / People core | `candidate_worker_conversion_record` with candidate, person, employment, selection decision, audit event and outbox evidence | PostgreSQL exact hire/evidence/audit-envelope binding, correction provenance, tenant RLS, legacy-write rejection and bitemporal history contract | ADR-0001, ADR-0003, ADR-0006 | implemented_on_protected_main | | GET-only People API | People API / purpose-bound read boundary | `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}`, `read_worker_people_record()`, `PostgresPeopleReadPort` | People API HTTP and PostgreSQL read contracts with exact 100% owned statement/branch coverage; current conversion lineage; no mutation writes | ADR-0002, ADR-0008 | implemented_on_protected_main | -| Governed People writes and confirmed-hire materialization | People API / purpose-bound mutation boundary | `POST /v1/employment-records`, `POST /v1/position-records`, `POST /v1/assignment-records`, `POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions`, `people_mutation_idempotency_record` | People command/HTTP/PostgreSQL contracts with exact owned statement/branch coverage plus PostgreSQL tenant-RLS, atomic audit/outbox/idempotency, identical-retry replay, changed-command rejection, rollback, and concurrent-key regression | ADR-0002, ADR-0006, ADR-0008 | implemented_on_protected_main | +| Governed People writes and confirmed-hire materialization | People API / purpose-bound mutation boundary | `POST /v1/employment-records`, `POST /v2/employment-records`, `POST /v1/position-records`, `POST /v1/assignment-records`, `POST /v1` and `/v2` `/tenants/{tenant_record_id}/candidate-worker-conversions`, `people_mutation_idempotency_record` | People command/HTTP/PostgreSQL contracts with exact owned statement/branch coverage plus PostgreSQL tenant-RLS, atomic audit/outbox/idempotency, identical-retry replay, changed-command rejection, rollback, concurrent-key regression, and V1-to-V2 compatibility coverage | ADR-0002, ADR-0006, ADR-0008, ADR-0141 | implemented_on_active_pr | | Evidence-grounded Job analysis with governed Task/FJA/KSAO persistence | Job Analysis / Workforce Validation | `JobAnalysisSnapshot`, `TaskEvidence`, `KSAORequirement`, `FunctionalJobAnalysisProfile`, `TaskKSAOLink`, `EvidenceSource`, `job_analysis_snapshot`, `job_analysis_task_item`, `job_analysis_ksao_item`, `job_analysis_task_ksao_link`, `job_analysis_write_command`, `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots`, `GET /v1/tenants/{tenant_record_id}/job-analysis-snapshots/{analysis_record_id}` | domain tenant/Job isolation, source/version/digest provenance, task-KSAO completeness, deterministic canonicalization, accountable human-review and LLM-draft-only regressions; migration 0013 PostgreSQL parent-scope/RLS/append-only/idempotency/audit-outbox persistence; exact route/OpenAPI/error contracts and 100% owned service statement/branch coverage | ADR-0007, ADR-0014 | implemented_on_active_pr | | Job-, cycle-, and staffing-scoped performance criterion observations | Performance / Workforce Validation | `criterion_observation`, `criterion_blueprint`, `performance_cycle`, `assignment_record`, `employment_record_version`, `position_record`, `position_record_version` | PostgreSQL wrong-Job, pre-assignment, out-of-cycle, frozen-Position, terminated-employment, closed-recorded-time, and session-TimeZone/UTC-midnight rejection plus valid worker-Job/staffing acceptance | ADR-0009 | implemented_on_protected_main | | Governed immutable audit and transactional outbox persistence | Audit Provenance / Integration Hub | `AuditOutboxEvent.canonical_json()`, `audit_event_record`, `outbox_delivery_record`, SHA-256 envelope digest | canonical-byte/digest regression plus PostgreSQL digest, allowlist/PII, high-impact confirmation, append-only, atomicity, lease-transition, terminal-state, and reserved-UUID tests | ADR-0006 | implemented_on_active_pr | diff --git a/docs/adr/0141-employment-employing-organization.md b/docs/adr/0141-employment-employing-organization.md index 80c51083d..f8d1b638a 100644 --- a/docs/adr/0141-employment-employing-organization.md +++ b/docs/adr/0141-employment-employing-organization.md @@ -23,6 +23,8 @@ Because the exact-one rule spans Employment versions, employer relationships, an The relationship is independent of Position and Assignment. It stores no Person PII, compensation, payroll, tax, benefits, statutory-account, candidate, performance, or model-output fields. +The API compatibility boundary is explicit. Existing `/v1` Employment and confirmed-hire payloads remain available for legacy terminated writes, while an active or leave `/v1` payload without employer facts fails before persistence with migration guidance. Employer-required writes use `/v2`; this keeps the exact-one database invariant intact without silently inventing a legal employer or changing the meaning of an existing V1 request. + History is correction-not-rewrite: business fields cannot be updated in place; the current recorded interval may only be closed and a replacement fact inserted. DELETE and TRUNCATE are rejected. Tenant-qualified foreign keys and forced RLS independently protect cross-tenant integrity and visibility. ## Consequences diff --git a/docs/doctoring/employment-employing-organization.md b/docs/doctoring/employment-employing-organization.md index 6b5f4ac23..22ab67c2e 100644 --- a/docs/doctoring/employment-employing-organization.md +++ b/docs/doctoring/employment-employing-organization.md @@ -18,6 +18,8 @@ The active PR instead records a tenant-scoped, bitemporal `employment_employing_ The People employment mutation and confirmed-hire materializer write this relationship in the same tenant-bound transaction that creates the Employment version. Terminated Employment creation does not create a relationship row because the database contract requires employers only for active/leave Employment coordinates. +The API boundary follows the versioning and path/schema semantics of OpenAPI Specification v3.2.0: the former `/v1` payload remains available for legacy terminated writes, while employer-required Employment and confirmed-hire commands are exposed under `/v2`. An active or leave V1 request without employer facts is rejected before persistence with a V2 migration action, preserving both legacy wire compatibility and the exact-one database invariant. + This is an HRIS source-of-truth relationship, not a claim of payroll or statutory-system ownership. ## Reference (APA 7) diff --git a/manifest.json b/manifest.json index 9c6809f82..d4ebb7525 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"a2293c3462a44ba1d7667a1c3ac17b4beaba56fe218353a53f2fe451506c3761","bytes":18210,"lines":78},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"database/migrations/0040_employment_employing_organization.sql","sha256":"1c2c61aa0e66a67e789a8d2132fe621fd7488cd578428c5dad10e4d48b85c485","bytes":18725,"lines":443},{"path":"docs/API_CONTRACT.md","sha256":"3c449f7c1b7d25614bb791f60a2ffdeef2ed812777f4d9038420ba16852ea2e5","bytes":5340,"lines":84},{"path":"docs/DATA_MODEL.md","sha256":"acd720587a0340993bdd2fae6625a4b7d7fa88a7ddfc24cf35823bb1fbb600d0","bytes":14063,"lines":88},{"path":"docs/ERD.md","sha256":"75299bf271fe99ee5137e1b02b9ca30af6d528adf546ed430d68993a18a6f9fa","bytes":7780,"lines":74},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"e112826e47079eb7225c7e683e361501a46ac4432818a09b263a27770a0377ad","bytes":11359,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8","bytes":16534,"lines":135},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"ad4593172aa0492fc3d8caab4d84f3dad26e745036fccad1f5175fe231855394","bytes":12186,"lines":41},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/0141-employment-employing-organization.md","sha256":"d435a1eae3f4c7c795006f744ee46a98db4a6f9ddae01695f1f9c6d9ea2c85d1","bytes":5910,"lines":61},{"path":"docs/adr/README.md","sha256":"dfb41046f5fd0511bb2907f93fda9a65f5b5b6c37b350603240291e525e9524b","bytes":1958,"lines":19},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/doctoring/employment-employing-organization.md","sha256":"84c697134340ac9690d6716595c7336c9a58777d5d91741cfa51cb2ba08bd51c","bytes":2598,"lines":25},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"30ec8533247a74659cc3251312c2d6ade75dffdd179d0117084c34e3704f0d92","bytes":1717,"lines":10},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"b41582fb5c593f0d178a3dc34151be00eaf7cd8073174f47440b74cf12c26501","bytes":29630,"lines":1024},{"path":"scripts/foundation-contract-core.mjs","sha256":"b2a5939f6a9e4590beb17fe2e717e0615e4d9baf74ef0e3d85f6a0d85dec1fd8","bytes":28530,"lines":695},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"c046ebb5b5b4800e24a8450ad549df29e1472552129a68b33e90daf8f43f7e17","bytes":15748,"lines":408},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_employment_employing_organization_postgres.sh","sha256":"1b8f8b76fc31758c02bede2959ee86c014d4380759744752e6c943b5cd437aae","bytes":16326,"lines":204},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"d01f63265f9970f162c8574f9887c6c7af1f4630e930aeb38bc654e2fe444316","bytes":3454,"lines":102},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"e70ed4b0c7ef8bd24e362ff8e9f04336c1c21766ecdf228d59bf2a32df876986","bytes":28573,"lines":655}]} \ No newline at end of file +{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"3f338a23c1d60789637fb3d88190ecf3d7daf7d55d684133acea82f45f051fd8","bytes":18526,"lines":79},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"49dd297c25ca78f30791ca49bf91cb4d2c701e53ffe21a350a9c0e6cdab50f09","bytes":3979,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"database/migrations/0040_employment_employing_organization.sql","sha256":"1c2c61aa0e66a67e789a8d2132fe621fd7488cd578428c5dad10e4d48b85c485","bytes":18725,"lines":443},{"path":"docs/API_CONTRACT.md","sha256":"a2d6b148246792d351d8b9b53c69413f972e3b23eb37cbce4a2e86f20ed15524","bytes":5912,"lines":91},{"path":"docs/DATA_MODEL.md","sha256":"acd720587a0340993bdd2fae6625a4b7d7fa88a7ddfc24cf35823bb1fbb600d0","bytes":14063,"lines":88},{"path":"docs/ERD.md","sha256":"75299bf271fe99ee5137e1b02b9ca30af6d528adf546ed430d68993a18a6f9fa","bytes":7780,"lines":74},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"bd842284efa1d19a8c3bf2141ad75f58775f81e18bede8c9ddb2d0dbaa711fe6","bytes":11523,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"ac3603353d330ea139732d755552f44ce54de6a95917bd425a922893e9e69209","bytes":16755,"lines":136},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"a4ede2a4a24cd18a7d2d4dcf172067d9fac4189e08b54ef847517bc33e01ebcd","bytes":12268,"lines":41},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/0141-employment-employing-organization.md","sha256":"ae85b37b4887553438aa59dbf9777f4da16f3e036d75f4d451da627c9a2bd0b1","bytes":6350,"lines":63},{"path":"docs/adr/README.md","sha256":"dfb41046f5fd0511bb2907f93fda9a65f5b5b6c37b350603240291e525e9524b","bytes":1958,"lines":19},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/doctoring/employment-employing-organization.md","sha256":"76be01aeb60ab2724256814e0cb58433a74554dd7c68a34add4ee9bc37d390ec","bytes":3051,"lines":27},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"30ec8533247a74659cc3251312c2d6ade75dffdd179d0117084c34e3704f0d92","bytes":1717,"lines":10},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"2807ab3fa58c5b60001e2963720c95541d5a069f07cdd017df77c7f55bac9775","bytes":30719,"lines":1063},{"path":"scripts/foundation-contract-core.mjs","sha256":"b2a5939f6a9e4590beb17fe2e717e0615e4d9baf74ef0e3d85f6a0d85dec1fd8","bytes":28530,"lines":695},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"c046ebb5b5b4800e24a8450ad549df29e1472552129a68b33e90daf8f43f7e17","bytes":15748,"lines":408},{"path":"tests/openapi-contract.test.mjs","sha256":"3f5fb6bb49854eb41705c5b799454a1e7f5f43b3081869c6746205a8e6d401c0","bytes":6980,"lines":208},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_employment_employing_organization_postgres.sh","sha256":"1b8f8b76fc31758c02bede2959ee86c014d4380759744752e6c943b5cd437aae","bytes":16326,"lines":204},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"d01f63265f9970f162c8574f9887c6c7af1f4630e930aeb38bc654e2fe444316","bytes":3454,"lines":102},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"6e492659326451076f37b9636f1e87048beb602ad1fc4121bcb02266aa551656","bytes":28616,"lines":656}]} diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 7ae9e61f7..0cbbd1c81 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -522,6 +522,45 @@ components: type: string format: uuid CreateEmploymentRecordCommand: + type: object + additionalProperties: false + required: + - person_record_id + - employment_status_code + - employment_concurrency_code + - effective_from + - decision_reason + - confirmation_reference + - evidence_references + properties: + person_record_id: + type: string + format: uuid + employment_status_code: + type: string + pattern: '^(active|leave|terminated)$' + employment_concurrency_code: + type: string + pattern: '^(exclusive|concurrent)$' + effective_from: + type: string + format: date + decision_reason: + type: string + minLength: 1 + maxLength: 4000 + confirmation_reference: + type: string + minLength: 1 + maxLength: 300 + evidence_references: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + $ref: '#/components/schemas/EvidenceReference' + CreateEmploymentRecordCommandV2: type: object additionalProperties: false required: diff --git a/services/people-api/README.md b/services/people-api/README.md index 548a83446..264ae0998 100644 --- a/services/people-api/README.md +++ b/services/people-api/README.md @@ -12,8 +12,8 @@ The service exposes a governed hire-to-employment read contract. `read_worker_pe The People API quality workflow is part of this contract and must run for pull requests to every supported protected/default integration branch, including `develop`. Its service tests enforce 100% owned statement and branch coverage and include regression coverage for the workflow dispatch boundary and HTTP security/transport behavior. -`HireAcceptanceAsgiApp` exposes confirmed-hire materialization as `POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire`. Authentication and tenant binding occur before request-body parsing, so an unauthenticated or foreign-tenant caller cannot use body parsing or command construction as an oracle. Authenticated requests then pass the validated `Idempotency-Key`, content-type, JSON/schema, authorization, and governed command checks under a 64 KiB cumulative request-body limit, at most 1024 ASGI request frames, and 128 nested JSON containers below the top-level command object. `PostgresHireAcceptancePort` acquires a transaction-scoped lock for the tenant/route/key before it persists Person, Employment, `candidate_worker_conversion_record`, governed audit/outbox evidence, and `people_mutation_idempotency_record` in one tenant-bound transaction. An exact retry returns the first committed person/employment/conversion identities without repeating necessary PII, audit, or outbox writes; reusing the key for changed command semantics fails closed. The legacy `candidate_worker_link` write path is not used. +`HireAcceptanceAsgiApp` exposes confirmed-hire materialization as `POST /v1` or `/v2` `/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire`. Authentication and tenant binding occur before request-body parsing, so an unauthenticated or foreign-tenant caller cannot use body parsing or command construction as an oracle. Authenticated requests then pass the validated `Idempotency-Key`, content-type, JSON/schema, authorization, and governed command checks under a 64 KiB cumulative request-body limit, at most 1024 ASGI request frames, and 128 nested JSON containers below the top-level command object. The legacy `/v1` terminated payload remains accepted; active and leave payloads without employer facts receive a migration error before persistence, while `/v2` requires those facts. `PostgresHireAcceptancePort` acquires a transaction-scoped lock for the tenant/route/key before it persists Person, Employment, `candidate_worker_conversion_record`, governed audit/outbox evidence, and `people_mutation_idempotency_record` in one tenant-bound transaction. An exact retry returns the first committed person/employment/conversion identities without repeating necessary PII, audit, or outbox writes; reusing the key for changed command semantics fails closed. The legacy `candidate_worker_link` write path is not used. -`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. +`PeopleMutationAsgiApp` exposes the governed People mutation API as `POST /v1/employment-records`, `POST /v2/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`. Legacy `/v1` terminated employment payloads remain accepted; active and leave `/v1` payloads without employer facts fail before persistence with migration guidance, and `/v2` requires the employer target. 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. 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/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index ce3b22ee4..88bb22817 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -52,14 +52,14 @@ class HireAcceptanceCommand: """ tenant_record_id: UUID - employing_organization_unit_id: UUID + employing_organization_unit_id: UUID | None candidate_profile_id: UUID selection_decision_id: UUID person_record_id: UUID person_name_record_id: UUID employment_record_id: UUID employment_record_version_id: UUID - employment_employing_organization_record_id: UUID + employment_employing_organization_record_id: UUID | None candidate_worker_conversion_record_id: UUID audit_event_record_id: UUID outbox_delivery_record_id: UUID @@ -72,19 +72,28 @@ def __post_init__(self) -> None: """Fail closed before authorization or persistence on malformed input.""" for field_name in ( "tenant_record_id", - "employing_organization_unit_id", "candidate_profile_id", "selection_decision_id", "person_record_id", "person_name_record_id", "employment_record_id", "employment_record_version_id", - "employment_employing_organization_record_id", "candidate_worker_conversion_record_id", "audit_event_record_id", "outbox_delivery_record_id", ): _validate_operational_uuid(field_name, getattr(self, field_name)) + if (self.employing_organization_unit_id is None) != ( + self.employment_employing_organization_record_id is None + ): + raise ValueError("employing organization fields must be supplied together.") + for field_name in ( + "employing_organization_unit_id", + "employment_employing_organization_record_id", + ): + value = getattr(self, field_name) + if value is not None: + _validate_operational_uuid(field_name, value) if type(self.effective_from) is not date: raise ValueError("effective_from must be a business date.") if not isinstance(self.display_name, str): @@ -103,6 +112,8 @@ def __post_init__(self) -> None: or _STATUS_CODE_PATTERN.fullmatch(self.employment_status_code) is None ): raise ValueError("employment_status_code must be a lower snake_case code.") + if self.employment_status_code in {"active", "leave"} and self.employing_organization_unit_id is None: + raise ValueError("active or leave hire requires an employing organization.") @dataclass(frozen=True, slots=True) @@ -166,7 +177,11 @@ def accept_confirmed_hire( resource_kind="selection_decision", requested_fields=_HIRE_MUTATION_FIELDS, policy=policy, - required_target_scope_code=organization_unit_scope_code(command.employing_organization_unit_id), + required_target_scope_code=( + organization_unit_scope_code(command.employing_organization_unit_id) + if command.employing_organization_unit_id is not None + else None + ), ) result = mutation_port.accept_hire(command=command, authorization=authorization) if not isinstance(result, HireAcceptanceResult): diff --git a/services/people-api/src/orgmetra_people_api/hire_http.py b/services/people-api/src/orgmetra_people_api/hire_http.py index cf6fbff7a..a34a09770 100644 --- a/services/people-api/src/orgmetra_people_api/hire_http.py +++ b/services/people-api/src/orgmetra_people_api/hire_http.py @@ -41,7 +41,8 @@ from orgmetra_people_api.mutations import validate_idempotency_key _LOGGER = logging.getLogger(__name__) -_ROUTE_PREFIX = ("v1", "tenants") +_ROUTE_VERSIONS = frozenset({"v1", "v2"}) +_ROUTE_PREFIX = "tenants" _ROUTE_LEAF = "candidate-worker-conversions" _PURPOSE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") _RFC3339_FULL_DATE = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z", flags=re.ASCII) @@ -50,7 +51,23 @@ _MAX_BODY_FRAMES = 1024 _MAX_JSON_NESTING_DEPTH = 128 _SUPPORT_REFERENCE_RANDOM_BYTES = 24 -_REQUIRED_BODY_KEYS = frozenset( +_V1_REQUIRED_BODY_KEYS = frozenset( + { + "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", + "effective_from", + "display_name", + "employment_status_code", + } +) +_V2_REQUIRED_BODY_KEYS = frozenset( { "employing_organization_unit_id", "candidate_profile_id", @@ -130,7 +147,7 @@ class HireAcceptanceAsgiApp: Supported route:: - POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions + POST /v1 or /v2/tenants/{tenant_record_id}/candidate-worker-conversions ?purpose=candidate_hire Successful responses contain only opaque worker identities. Display names and @@ -176,14 +193,16 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send status=404, payload={ "error": "route_not_found", - "message": "Use /v1/tenants/{tenant_record_id}/candidate-worker-conversions.", + "message": "Use /v1 or /v2/tenants/{tenant_record_id}/candidate-worker-conversions.", }, ) return try: - tenant_record_id, purpose_code = _parse_hire_route(path, scope.get("query_string", b"")) - except (_InvalidHttpRequest, ValueError, TypeError): + tenant_record_id, purpose_code, api_version = _parse_hire_route( + path, scope.get("query_string", b"") + ) + except (ValueError, TypeError): await _send_json( send, status=400, @@ -247,7 +266,12 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send idempotency_key = _parse_idempotency_key(scope) _require_json_content_type(scope) payload = await _read_json_object(receive) - command = _command_from_payload(tenant_record_id, payload, idempotency_key) + command = _command_from_payload( + tenant_record_id, + payload, + idempotency_key, + api_version=api_version, + ) except _PayloadTooLarge: await _send_json( send, @@ -268,13 +292,13 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send }, ) return - except (_InvalidHttpRequest, ValueError, TypeError): + except (_InvalidHttpRequest, ValueError, TypeError) as error: await _send_json( send, status=400, payload={ "error": "invalid_request", - "message": "Correct the idempotency key and hire command fields, then retry.", + "message": str(error), }, ) return @@ -354,10 +378,15 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send def _looks_like_hire_route(path: str) -> bool: """Recognize only the versioned hire-acceptance route shape.""" parts = path.strip("/").split("/") - return len(parts) == 4 and tuple(parts[:2]) == _ROUTE_PREFIX and parts[3] == _ROUTE_LEAF + return ( + len(parts) == 4 + and parts[0] in _ROUTE_VERSIONS + and parts[1] == _ROUTE_PREFIX + and parts[3] == _ROUTE_LEAF + ) -def _parse_hire_route(path: str, raw_query: object) -> tuple[UUID, str]: +def _parse_hire_route(path: str, raw_query: object) -> tuple[UUID, str, str]: """Validate tenant and purpose before authentication and body interpretation.""" parts = path.strip("/").split("/") try: @@ -389,7 +418,7 @@ def _parse_hire_route(path: str, raw_query: object) -> tuple[UUID, str]: purpose_code = query["purpose"] if _PURPOSE_PATTERN.fullmatch(purpose_code) is None: raise _InvalidHttpRequest("purpose must be a lower snake_case code") - return tenant_record_id, purpose_code + return tenant_record_id, purpose_code, parts[0] def _parse_idempotency_key(scope: Mapping[str, object]) -> str: @@ -503,19 +532,32 @@ def _command_from_payload( tenant_record_id: UUID, payload: Mapping[str, object], idempotency_key: str, + *, + api_version: str = "v2", ) -> HireAcceptanceCommand: """Map one exact JSON object and validated idempotency key onto the hire command.""" - if frozenset(payload) != _REQUIRED_BODY_KEYS: + if api_version not in _ROUTE_VERSIONS: + raise _InvalidHttpRequest("unsupported hire API version") + expected_keys = _V2_REQUIRED_BODY_KEYS if api_version == "v2" else _V1_REQUIRED_BODY_KEYS + if frozenset(payload) != expected_keys: raise _InvalidHttpRequest("hire command fields are incomplete or unsupported") try: effective_from_raw = _require_string_field(payload, "effective_from") if _RFC3339_FULL_DATE.fullmatch(effective_from_raw) is None: raise _InvalidHttpRequest("effective_from must be an RFC 3339 full-date") effective_from = date.fromisoformat(effective_from_raw) + employment_status_code = _require_string_field(payload, "employment_status_code") + if api_version == "v1" and employment_status_code in {"active", "leave"}: + raise _InvalidHttpRequest( + "active or leave hire requires /v2/tenants/{tenant_record_id}/candidate-worker-conversions " + "with employing organization fields" + ) return HireAcceptanceCommand( tenant_record_id=tenant_record_id, - employing_organization_unit_id=UUID( - _require_string_field(payload, "employing_organization_unit_id") + employing_organization_unit_id=( + UUID(_require_string_field(payload, "employing_organization_unit_id")) + if api_version == "v2" + else None ), candidate_profile_id=UUID(_require_string_field(payload, "candidate_profile_id")), selection_decision_id=UUID(_require_string_field(payload, "selection_decision_id")), @@ -525,8 +567,10 @@ def _command_from_payload( employment_record_version_id=UUID( _require_string_field(payload, "employment_record_version_id") ), - employment_employing_organization_record_id=UUID( - _require_string_field(payload, "employment_employing_organization_record_id") + employment_employing_organization_record_id=( + UUID(_require_string_field(payload, "employment_employing_organization_record_id")) + if api_version == "v2" + else None ), candidate_worker_conversion_record_id=UUID( _require_string_field(payload, "candidate_worker_conversion_record_id") @@ -538,7 +582,9 @@ def _command_from_payload( effective_from=effective_from, display_name=_require_string_field(payload, "display_name"), idempotency_key=idempotency_key, - employment_status_code=_require_string_field(payload, "employment_status_code"), + employment_status_code=employment_status_code, ) + except _InvalidHttpRequest: + raise except (TypeError, ValueError) as error: raise _InvalidHttpRequest("hire command fields are invalid") from error diff --git a/services/people-api/src/orgmetra_people_api/mutation_http.py b/services/people-api/src/orgmetra_people_api/mutation_http.py index bbc1484cf..80b74cf9e 100644 --- a/services/people-api/src/orgmetra_people_api/mutation_http.py +++ b/services/people-api/src/orgmetra_people_api/mutation_http.py @@ -47,7 +47,18 @@ _RFC3339_FULL_DATE = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z", flags=re.ASCII) _PURPOSE_CODE_PATTERN = re.compile(r"\A[a-z][a-z0-9_]{2,63}\Z", flags=re.ASCII) _MAX_UUID_INT = (1 << 128) - 1 -_EMPLOYMENT_BODY_KEYS = frozenset( +_EMPLOYMENT_V1_BODY_KEYS = frozenset( + { + "person_record_id", + "employment_status_code", + "employment_concurrency_code", + "effective_from", + "decision_reason", + "confirmation_reference", + "evidence_references", + } +) +_EMPLOYMENT_V2_BODY_KEYS = frozenset( { "employing_organization_unit_id", "person_record_id", @@ -142,11 +153,12 @@ async def _send_error( @dataclass(frozen=True, slots=True) class PeopleMutationAsgiApp: - """Expose the three governed People mutation routes through one ASGI app. + """Expose governed People mutation routes through one ASGI app. Supported routes:: - POST /v1/employment-records + POST /v1/employment-records (legacy terminated payload) + POST /v2/employment-records POST /v1/position-records POST /v1/assignment-records @@ -203,7 +215,7 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send status=404, payload={ "error": "route_not_found", - "message": "Use /v1/employment-records, /v1/position-records, or /v1/assignment-records.", + "message": "Use /v1/employment-records, /v2/employment-records, /v1/position-records, or /v1/assignment-records.", }, ) return @@ -221,7 +233,7 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send }, ) return - except (_InvalidHttpRequest, ValueError, TypeError): + except (ValueError, TypeError): await _send_error( send, status=400, @@ -303,13 +315,13 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send }, ) return - except (_InvalidHttpRequest, ValueError, TypeError): + except (_InvalidHttpRequest, ValueError, TypeError) as error: await _send_error( send, status=400, payload={ "error": "invalid_request", - "message": "Correct the tenant, actor, purpose, confirmation, evidence, and command fields, then retry.", + "message": str(error), }, ) return @@ -388,7 +400,11 @@ def _mutation_route(path: object) -> str | None: if not isinstance(path, str): return None parts = path.strip("/").split("/") - if len(parts) != 2 or parts[0] != "v1": + if len(parts) != 2: + return None + if parts[0] == "v2" and parts[1] == "employment-records": + return "employment-records-v2" + if parts[0] != "v1": return None if parts[1] in {"employment-records", "position-records", "assignment-records"}: return parts[1] @@ -540,21 +556,30 @@ def _command_for_route( evidence_version_code = _governance_evidence_binding(payload) confirmation_reference = _require_confirmation_reference(payload) effective_from = _parse_effective_date(payload) - if route == "employment-records": - if frozenset(payload) != _EMPLOYMENT_BODY_KEYS: + if route in {"employment-records", "employment-records-v2"}: + is_v2 = route == "employment-records-v2" + expected_keys = _EMPLOYMENT_V2_BODY_KEYS if is_v2 else _EMPLOYMENT_V1_BODY_KEYS + if frozenset(payload) != expected_keys: raise _InvalidHttpRequest("employment command fields are incomplete or unsupported") + employment_status_code = _require_string_field(payload, "employment_status_code") + if not is_v2 and employment_status_code in {"active", "leave"}: + raise _InvalidHttpRequest( + "active or leave Employment requires /v2/employment-records with employing_organization_unit_id" + ) return EmploymentMutationCommand( tenant_record_id=tenant_record_id, - employing_organization_unit_id=UUID( - _require_string_field(payload, "employing_organization_unit_id") + employing_organization_unit_id=( + UUID(_require_string_field(payload, "employing_organization_unit_id")) + if is_v2 + else None ), person_record_id=UUID(_require_string_field(payload, "person_record_id")), employment_record_id=id_factory(), employment_record_version_id=id_factory(), - employment_employing_organization_record_id=id_factory(), + employment_employing_organization_record_id=id_factory() if is_v2 else None, audit_event_record_id=id_factory(), outbox_delivery_record_id=id_factory(), - employment_status_code=_require_string_field(payload, "employment_status_code"), + employment_status_code=employment_status_code, employment_concurrency_code=_require_string_field(payload, "employment_concurrency_code"), effective_from=effective_from, confirmation_reference=confirmation_reference, @@ -605,7 +630,7 @@ def _dispatch_mutation( app: PeopleMutationAsgiApp, ) -> tuple[dict[str, str], str]: """Invoke the authorized application function for the matched route.""" - if route == "employment-records": + if route in {"employment-records", "employment-records-v2"}: if not isinstance(command, EmploymentMutationCommand): raise TypeError("employment route requires EmploymentMutationCommand") result = create_employment_record( @@ -616,7 +641,8 @@ def _dispatch_mutation( mutation_port=app.mutation_port, ) created = str(result.employment_record_id) - return {"employment_record_id": created}, f"/v1/employment-records/{created}" + version = "v2" if route == "employment-records-v2" else "v1" + return {"employment_record_id": created}, f"/{version}/employment-records/{created}" if route == "position-records": if not isinstance(command, PositionMutationCommand): raise TypeError("position route requires PositionMutationCommand") diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 687dc09e2..9ea1c1af0 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -123,7 +123,11 @@ def mutation_command_digest( "confirmation_reference": command.confirmation_reference, "effective_from": command.effective_from.isoformat(), "employment_concurrency_code": command.employment_concurrency_code, - "employing_organization_unit_id": str(command.employing_organization_unit_id), + "employing_organization_unit_id": ( + None + if command.employing_organization_unit_id is None + else str(command.employing_organization_unit_id) + ), "employment_status_code": command.employment_status_code, "evidence_version_code": command.evidence_version_code, "person_record_id": str(command.person_record_id), @@ -167,11 +171,11 @@ class EmploymentMutationCommand: """Opaque identities and high-impact evidence needed to create one employment.""" tenant_record_id: UUID - employing_organization_unit_id: UUID + employing_organization_unit_id: UUID | None person_record_id: UUID employment_record_id: UUID employment_record_version_id: UUID - employment_employing_organization_record_id: UUID + employment_employing_organization_record_id: UUID | None audit_event_record_id: UUID outbox_delivery_record_id: UUID employment_status_code: str @@ -185,19 +189,30 @@ def __post_init__(self) -> None: """Fail closed before authorization or persistence on malformed input.""" for field_name in ( "tenant_record_id", - "employing_organization_unit_id", "person_record_id", "employment_record_id", "employment_record_version_id", - "employment_employing_organization_record_id", "audit_event_record_id", "outbox_delivery_record_id", ): _validate_operational_uuid(field_name, getattr(self, field_name)) + if (self.employing_organization_unit_id is None) != ( + self.employment_employing_organization_record_id is None + ): + raise ValueError("employing organization fields must be supplied together.") + for field_name in ( + "employing_organization_unit_id", + "employment_employing_organization_record_id", + ): + value = getattr(self, field_name) + if value is not None: + _validate_operational_uuid(field_name, value) 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 self.employment_status_code in {"active", "leave"} and self.employing_organization_unit_id is None: + raise ValueError("active or leave employment requires an employing organization.") if ( not isinstance(self.employment_concurrency_code, str) or self.employment_concurrency_code not in _CONCURRENCY_CODES @@ -381,7 +396,11 @@ def create_employment_record( resource_kind="employment_record", requested_fields=_EMPLOYMENT_FIELDS, policy=policy, - required_target_scope_code=organization_unit_scope_code(command.employing_organization_unit_id), + required_target_scope_code=( + organization_unit_scope_code(command.employing_organization_unit_id) + if command.employing_organization_unit_id is not None + else None + ), ) result = port.create_employment(command=command, authorization=authorization) if not isinstance(result, EmploymentMutationResult): 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 a05276f1a..2cc1cab42 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -188,7 +188,11 @@ def _is_aware_datetime(value: object) -> bool: def _validate_authorization(command: HireAcceptanceCommand, authorization: object) -> AuthorizationDecision: """Require an exact allow decision for this selection decision and employer target.""" expected_reference = f"selection_decision:{command.selection_decision_id.hex}" - expected_target_scope = organization_unit_scope_code(command.employing_organization_unit_id) + expected_target_scope = ( + organization_unit_scope_code(command.employing_organization_unit_id) + if command.employing_organization_unit_id is not None + else None + ) if not isinstance(authorization, AuthorizationDecision): raise HireDecisionIntegrityError("hire mutation requires a typed authorization decision") if ( @@ -441,6 +445,8 @@ def accept_hire( ), ) if command.employment_status_code in {"active", "leave"}: + assert command.employing_organization_unit_id is not None + assert command.employment_employing_organization_record_id is not None cursor.execute( _INSERT_EMPLOYMENT_EMPLOYING_ORGANIZATION_SQL, ( 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 dafa4b6f4..1bee65b18 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -556,7 +556,11 @@ def create_employment( resource_reference=f"employment_record:{command.employment_record_id.hex}", resource_kind="employment_record", requested_fields=_EMPLOYMENT_FIELDS, - required_target_scope_code=organization_unit_scope_code(command.employing_organization_unit_id), + required_target_scope_code=( + organization_unit_scope_code(command.employing_organization_unit_id) + if command.employing_organization_unit_id is not None + else None + ), ) with self.connection_factory() as connection: with connection.cursor() as cursor: @@ -617,6 +621,8 @@ def create_employment( ), ) if command.employment_status_code in {"active", "leave"}: + assert command.employing_organization_unit_id is not None + assert command.employment_employing_organization_record_id is not None cursor.execute( _INSERT_EMPLOYMENT_EMPLOYING_ORGANIZATION_SQL, ( diff --git a/services/people-api/tests/test_api_versioning.py b/services/people-api/tests/test_api_versioning.py index 3820c51da..e78293884 100644 --- a/services/people-api/tests/test_api_versioning.py +++ b/services/people-api/tests/test_api_versioning.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from uuid import UUID import pytest @@ -124,3 +125,50 @@ def test_active_v1_payload_without_employer_is_rejected_with_migration_guidance( lambda: UUID("0198a412-8200-7000-8000-000000000042"), IDEMPOTENCY_KEY, ) + + +def test_commands_reject_partial_or_missing_active_employer_facts() -> None: + """Keep core commands fail-closed even when callers bypass the HTTP parsers.""" + employment = _command_for_route( + "employment-records-v2", + TENANT, + _employment_payload(status="active", include_employer=True), + lambda: UUID("0198a412-8200-7000-8000-000000000043"), + IDEMPOTENCY_KEY, + ) + with pytest.raises(ValueError, match="supplied together"): + replace(employment, employment_employing_organization_record_id=None) + with pytest.raises(ValueError, match="requires an employing organization"): + replace( + employment, + employing_organization_unit_id=None, + employment_employing_organization_record_id=None, + ) + + hire = _command_from_payload( + TENANT, + _hire_payload(status="active", include_employer=True), + IDEMPOTENCY_KEY, + ) + with pytest.raises(ValueError, match="supplied together"): + replace(hire, employment_employing_organization_record_id=None) + with pytest.raises(ValueError, match="requires an employing organization"): + replace( + hire, + employing_organization_unit_id=None, + employment_employing_organization_record_id=None, + ) + with pytest.raises(ValueError, match="unsupported hire API version"): + _command_from_payload( + TENANT, + _hire_payload(status="terminated", include_employer=False), + IDEMPOTENCY_KEY, + api_version="v3", + ) + with pytest.raises(ValueError, match="v2"): + _command_from_payload( + TENANT, + _hire_payload(status="active", include_employer=False), + IDEMPOTENCY_KEY, + api_version="v1", + ) diff --git a/services/people-api/tests/test_decision_reason_binding.py b/services/people-api/tests/test_decision_reason_binding.py index c2a4f85fb..fa6b7b3ee 100644 --- a/services/people-api/tests/test_decision_reason_binding.py +++ b/services/people-api/tests/test_decision_reason_binding.py @@ -55,7 +55,7 @@ def _payload(*, decision_reason: str, evidence_references: list[dict[str, str]] def _command(payload: dict[str, object]) -> EmploymentMutationCommand: """Build one deterministic employment command through the HTTP mapping boundary.""" command = _command_for_route( - "employment-records", + "employment-records-v2", TENANT, payload, _id_factory(iter(GENERATED_IDS)), diff --git a/services/people-api/tests/test_evidence_reference_binding_regression.py b/services/people-api/tests/test_evidence_reference_binding_regression.py index 5e77380cb..3489dbf1e 100644 --- a/services/people-api/tests/test_evidence_reference_binding_regression.py +++ b/services/people-api/tests/test_evidence_reference_binding_regression.py @@ -48,7 +48,7 @@ def employment_payload(evidence_references: list[object]) -> dict[str, object]: def command_for(evidence_references: list[object]): """Build one employment command from the public evidence-reference shape.""" return _command_for_route( - "employment-records", + "employment-records-v2", TENANT, employment_payload(evidence_references), SequentialIdFactory(), diff --git a/services/people-api/tests/test_hire_http_route.py b/services/people-api/tests/test_hire_http_route.py index 1796cda91..bb79d085c 100644 --- a/services/people-api/tests/test_hire_http_route.py +++ b/services/people-api/tests/test_hire_http_route.py @@ -29,7 +29,7 @@ CONVERSION = UUID("0198a412-7200-7000-8000-000000000040") AUDIT_EVENT = UUID("0198a412-7200-7000-8000-000000000050") OUTBOX_DELIVERY = UUID("0198a412-7200-7000-8000-000000000051") -ROUTE = f"/v1/tenants/{TENANT}/candidate-worker-conversions" +ROUTE = f"/v2/tenants/{TENANT}/candidate-worker-conversions" QUERY = b"purpose=candidate_hire" IDEMPOTENCY_KEY = b"hire-idempotency-key-17" diff --git a/services/people-api/tests/test_mutation_http_authentication_order.py b/services/people-api/tests/test_mutation_http_authentication_order.py index c185e70d4..93bc85dfc 100644 --- a/services/people-api/tests/test_mutation_http_authentication_order.py +++ b/services/people-api/tests/test_mutation_http_authentication_order.py @@ -142,7 +142,7 @@ def _scope() -> dict[str, object]: return { "type": "http", "method": "POST", - "path": "/v1/employment-records", + "path": "/v2/employment-records", "query_string": b"", "headers": [ (b"authorization", b"Bearer opaque-token"), @@ -236,7 +236,7 @@ async def send(message: dict[str, object]) -> None: self.assertEqual((start["status"], payload["error_code"]), (500, "internal_error")) self.assertEqual(len(captured.records), 1) record = captured.records[0] - self.assertEqual(record.route, "employment-records") + self.assertEqual(record.route, "employment-records-v2") self.assertEqual(record.tenant_record_id, str(TENANT)) self.assertEqual( record.correlation_reference, diff --git a/services/people-api/tests/test_mutation_http_route.py b/services/people-api/tests/test_mutation_http_route.py index df8dc39ec..0d93e5d08 100644 --- a/services/people-api/tests/test_mutation_http_route.py +++ b/services/people-api/tests/test_mutation_http_route.py @@ -227,7 +227,7 @@ async def _request( app: PeopleMutationAsgiApp, *, method: str = "POST", - path: object = "/v1/employment-records", + path: object = "/v2/employment-records", headers: object | None = None, body: object | None = None, more_body: bool = False, @@ -296,7 +296,7 @@ async def test_post_routes_return_opaque_created_identities(self) -> None: employment_status, employment_headers, employment_payload = await self._request(app) self.assertEqual(employment_status, 201) self.assertEqual(employment_payload, {"employment_record_id": str(EMPLOYMENT)}) - self.assertEqual(employment_headers[b"location"], f"/v1/employment-records/{EMPLOYMENT}".encode("ascii")) + self.assertEqual(employment_headers[b"location"], f"/v2/employment-records/{EMPLOYMENT}".encode("ascii")) self.assertEqual(employment_headers[b"cache-control"], b"no-store") command = port.employment_calls[0][0] self.assertEqual(command.person_record_id, PERSON) diff --git a/services/people-api/tests/test_mutation_http_schema_types.py b/services/people-api/tests/test_mutation_http_schema_types.py index a8d37fa94..7b2ce37bc 100644 --- a/services/people-api/tests/test_mutation_http_schema_types.py +++ b/services/people-api/tests/test_mutation_http_schema_types.py @@ -54,7 +54,7 @@ def test_integer_basic_iso_date_is_not_coerced_to_openapi_string_date(self) -> N """An integer YYYYMMDD must not cross the HTTP command boundary as a date string.""" with self.assertRaisesRegex(ValueError, "effective_from"): _command_for_route( - "employment-records", + "employment-records-v2", TENANT, employment_payload(effective_from=20260818), id_factory(), @@ -66,7 +66,7 @@ def test_non_rfc3339_iso_date_forms_are_rejected(self) -> None: for value in ("20260818", "2026-W34-2"): with self.subTest(value=value), self.assertRaisesRegex(ValueError, "effective_from"): _command_for_route( - "employment-records", + "employment-records-v2", TENANT, employment_payload(effective_from=value), id_factory(), @@ -77,7 +77,7 @@ def test_decision_reason_above_openapi_maximum_is_rejected(self) -> None: """Decision rationale must stay within the published 4000-character bound.""" with self.assertRaisesRegex(ValueError, "decision_reason"): _command_for_route( - "employment-records", + "employment-records-v2", TENANT, employment_payload(decision_reason="r" * 4001), id_factory(), @@ -90,7 +90,7 @@ def test_confirmation_reference_above_openapi_maximum_is_rejected(self) -> None: self.assertEqual(len(overlong_reference), 301) with self.assertRaisesRegex(ValueError, "confirmation_reference"): _command_for_route( - "employment-records", + "employment-records-v2", TENANT, employment_payload(confirmation_reference=overlong_reference), id_factory(), diff --git a/services/people-api/tests/test_support_reference_correlation.py b/services/people-api/tests/test_support_reference_correlation.py index 0b33126c0..c83e2dc89 100644 --- a/services/people-api/tests/test_support_reference_correlation.py +++ b/services/people-api/tests/test_support_reference_correlation.py @@ -213,7 +213,7 @@ async def test_confirmed_hire_500_support_reference_matches_error_log(self) -> N scope={ "type": "http", "method": "POST", - "path": f"/v1/tenants/{TENANT}/candidate-worker-conversions", + "path": f"/v2/tenants/{TENANT}/candidate-worker-conversions", "query_string": b"purpose=candidate_hire", "headers": [ (b"authorization", b"Bearer opaque-token"), @@ -255,7 +255,7 @@ async def test_people_mutation_500_support_reference_matches_error_log(self) -> scope={ "type": "http", "method": "POST", - "path": "/v1/employment-records", + "path": "/v2/employment-records", "query_string": b"", "headers": [ (b"authorization", b"Bearer opaque-token"), diff --git a/services/people-api/tests/test_support_reference_response_privacy.py b/services/people-api/tests/test_support_reference_response_privacy.py index 492495cc2..4397fe36d 100644 --- a/services/people-api/tests/test_support_reference_response_privacy.py +++ b/services/people-api/tests/test_support_reference_response_privacy.py @@ -50,7 +50,7 @@ async def test_confirmed_hire_persistence_secret_is_absent_from_response(self) - scope={ "type": "http", "method": "POST", - "path": f"/v1/tenants/{_SUPPORT.TENANT}/candidate-worker-conversions", + "path": f"/v2/tenants/{_SUPPORT.TENANT}/candidate-worker-conversions", "query_string": b"purpose=candidate_hire", "headers": [ (b"authorization", b"Bearer opaque-token"), @@ -92,7 +92,7 @@ async def test_people_mutation_persistence_secret_is_absent_from_response(self) scope={ "type": "http", "method": "POST", - "path": "/v1/employment-records", + "path": "/v2/employment-records", "query_string": b"", "headers": [ (b"authorization", b"Bearer opaque-token"), diff --git a/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 8e98078da..983c937f3 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -19,6 +19,19 @@ test('canonical OpenAPI passes structural operation validation', () => { assert.deepEqual(validateOpenApiContract(canonical), []); }); +test('employment command schemas preserve V1 compatibility and require employer in V2', () => { + const v1 = canonical.slice( + canonical.indexOf(' CreateEmploymentRecordCommand:\n'), + canonical.indexOf(' CreateEmploymentRecordCommandV2:\n') + ); + const v2 = canonical.slice( + canonical.indexOf(' CreateEmploymentRecordCommandV2:\n'), + canonical.indexOf(' CreatedEmploymentRecord:\n') + ); + assert.doesNotMatch(v1, /- employing_organization_unit_id\n/); + assert.match(v2, /- employing_organization_unit_id\n/); +}); + for (const testCase of [ { name: 'createPersonRecord path', @@ -164,7 +177,7 @@ for (const testCase of [ { name: 'assignment confirmation requirement', fragment: ' - confirmation_reference\n', - occurrence: 5, + occurrence: 6, expected: /CreateAssignmentRecordCommand.*confirmation/ } ]) { diff --git a/tests/validate_repository.py b/tests/validate_repository.py index 83c87fd77..ed9cfe61d 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -556,6 +556,7 @@ def _validate_openapi_contract() -> None: "CreateJobProfileCommand", "RecordSelectionDecisionCommand", "CreateEmploymentRecordCommand", + "CreateEmploymentRecordCommandV2", "CreatePositionRecordCommand", "CreateAssignmentRecordCommand", ):