diff --git a/.github/workflows/employment-employing-organization-quality.yml b/.github/workflows/employment-employing-organization-quality.yml new file mode 100644 index 000000000..355b85313 --- /dev/null +++ b/.github/workflows/employment-employing-organization-quality.yml @@ -0,0 +1,69 @@ +name: Employment Employing Organization Quality + +on: + pull_request: + branches: + - develop + paths: + - "database/migrations/0001_foundation_schema.sql" + - "database/migrations/0002_sealed_evidence_digest.sql" + - "database/migrations/0040_employment_employing_organization.sql" + - "tests/test_employment_employing_organization_postgres.sh" + - "scripts/foundation-contract-core.mjs" + - "tests/validate_repository.py" + - ".github/workflows/employment-employing-organization-quality.yml" + - "docs/adr/0141-employment-employing-organization.md" + - "docs/doctoring/employment-employing-organization.md" + - "docs/DATA_MODEL.md" + - "docs/ERD.md" + - "docs/TRACEABILITY.md" + - "CHANGELOG.md" + - "manifest.json" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: employment-employing-organization-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + postgres: + name: Employer scope bitemporal and tenant boundaries + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:16.14@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 + env: + POSTGRES_USER: orgmetra + POSTGRES_PASSWORD: orgmetra + POSTGRES_DB: orgmetra + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U orgmetra -d orgmetra" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://orgmetra:orgmetra@localhost:5432/orgmetra + steps: + - name: Checkout exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Prove exact candidate checkout + env: + ORGMETRA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$ORGMETRA_EXPECTED_HEAD_SHA" + - name: Print exact foundation manifest candidate + run: python tests/validate_repository.py --print-manifest + - name: Prove Employment employing-organization contract + run: bash tests/test_employment_employing_organization_postgres.sh + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f4752d7..3e75be9ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Orgmetra will be documented in this file. ### Added +- 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. - Active-PR `orgmetra_selection_review` packet for PII-minimized, evidence-bound human selection review: canonical operational tenant identity, UUID-backed opaque candidate/Job/sealed-evidence/reviewer references, explicit purpose/reason/evidence version, deterministic canonical JSON and SHA-256 correlation, mandatory human decision state, redacted packet repr, and provenance-paired model evidence that remains `untrusted_draft`, with exact 100% owned statement and branch coverage required by its quality gate. @@ -56,6 +57,7 @@ All notable changes to Orgmetra will be documented in this file. ### Security +- Employment and confirmed-hire writes now require an authenticated exact employing-organization target scope in addition to the coarse People operation scope; missing same-tenant organization authorization fails before persistence. - Predictive-validity cases fail closed when selection evidence, Job scope, study criterion, converted worker, or system-recorded visibility does not match; the normalized case relation is tenant-qualified, append-only, TRUNCATE-protected, and forced through row-level security. - Purpose-bound PII authorization now fails closed across active tenant, authenticated actor tenant, resource tenant, resource kind, purpose, operation, operation-specific Keyverse scope, and requested-field subset; malformed/wildcard-like attributes, mutable field/scope collections, reserved UUID sentinels, and cross-tenant confused-deputy contexts are rejected before protected values are returned. Authorization requests and allow/deny evidence now also require and preserve one namespaced opaque target-resource reference, so immutable audit correlation identifies the exact HR record without copying its protected values. Authorization evidence otherwise contains governance metadata and field names only, with stable denial reasons and actionable next steps rather than PII. - LLM output constrained to draft evidence. diff --git a/database/migrations/0040_employment_employing_organization.sql b/database/migrations/0040_employment_employing_organization.sql new file mode 100644 index 000000000..809ccbe38 --- /dev/null +++ b/database/migrations/0040_employment_employing_organization.sql @@ -0,0 +1,443 @@ +-- Employment employing-organization truth. +-- +-- An Employment and a Position answer different questions. A Position belongs +-- to an organizational seat; this relation records which legal organization +-- employs the worker for the Employment itself. It is intentionally separate +-- from Position/Assignment so a legal-employer change is not inferred from a +-- seat move and a seat move is not inferred from a legal-employer change. + +CREATE TABLE employment_employing_organization_record ( + tenant_record_id uuid NOT NULL REFERENCES tenant_record(tenant_record_id), + employment_employing_organization_record_id uuid PRIMARY KEY, + employment_record_id uuid NOT NULL, + employing_organization_unit_id uuid NOT NULL, + effective_from date NOT NULL, + effective_to date, + recorded_from timestamptz NOT NULL DEFAULT now(), + recorded_to timestamptz, + CONSTRAINT employment_employing_organization_employment_tenant_fk + FOREIGN KEY (tenant_record_id, employment_record_id) + REFERENCES employment_record(tenant_record_id, employment_record_id), + CONSTRAINT employment_employing_organization_unit_tenant_fk + FOREIGN KEY (tenant_record_id, employing_organization_unit_id) + REFERENCES organization_unit(tenant_record_id, organization_unit_id), + CONSTRAINT employment_employing_organization_effective_period_check + CHECK (effective_to IS NULL OR effective_to > effective_from), + CONSTRAINT employment_employing_organization_recorded_period_check + CHECK (recorded_to IS NULL OR recorded_to > recorded_from), + CONSTRAINT employment_employing_organization_tenant_operational_uuid_check + CHECK (is_operational_uuid(tenant_record_id)), + CONSTRAINT employment_employing_organization_record_operational_uuid_check + CHECK (is_operational_uuid(employment_employing_organization_record_id)), + CONSTRAINT employment_employing_organization_employment_uuid_check + CHECK (is_operational_uuid(employment_record_id)), + CONSTRAINT employment_employing_organization_unit_operational_uuid_check + CHECK (is_operational_uuid(employing_organization_unit_id)), + CONSTRAINT employment_employing_organization_tenant_identity_unique + UNIQUE (tenant_record_id, employment_employing_organization_record_id), + CONSTRAINT employment_employing_organization_bitemporal_exclusion + EXCLUDE USING gist ( + tenant_record_id WITH =, + employment_record_id WITH =, + daterange(effective_from, effective_to, '[)') WITH &&, + tstzrange(recorded_from, recorded_to, '[)') WITH && + ) +); + +CREATE INDEX employment_employing_organization_unit_lookup_index +ON employment_employing_organization_record ( + tenant_record_id, + employing_organization_unit_id +); + +-- The exact-one rule spans three relations. Deferred constraint triggers alone +-- can suffer READ COMMITTED write skew when two transactions validate before +-- either commit becomes visible. Serialize only mutations that can change this +-- invariant, scoped to one tenant. Hash collisions merely serialize unrelated +-- tenants; they cannot weaken integrity. +CREATE FUNCTION serialize_employment_employing_organization_invariant() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_tenant_record_id uuid; + v_organization_unit_id uuid; + touches_exact_one boolean := false; +BEGIN + IF TG_OP = 'DELETE' THEN + v_tenant_record_id := OLD.tenant_record_id; + ELSE + v_tenant_record_id := NEW.tenant_record_id; + END IF; + + IF TG_TABLE_NAME = 'employment_record_version' THEN + IF TG_OP = 'INSERT' THEN + touches_exact_one := NEW.employment_status_code IN ('active', 'leave'); + ELSIF TG_OP = 'DELETE' THEN + touches_exact_one := OLD.employment_status_code IN ('active', 'leave'); + ELSE + touches_exact_one := OLD.employment_status_code IN ('active', 'leave') + OR NEW.employment_status_code IN ('active', 'leave'); + END IF; + ELSIF TG_TABLE_NAME = 'organization_unit_version' THEN + IF TG_OP = 'DELETE' THEN + v_organization_unit_id := OLD.organization_unit_id; + touches_exact_one := OLD.organization_type_code = 'legal_entity'; + ELSIF TG_OP = 'INSERT' THEN + v_organization_unit_id := NEW.organization_unit_id; + touches_exact_one := NEW.organization_type_code = 'legal_entity'; + ELSE + v_organization_unit_id := NEW.organization_unit_id; + touches_exact_one := OLD.organization_type_code = 'legal_entity' + OR NEW.organization_type_code = 'legal_entity'; + END IF; + + IF NOT touches_exact_one THEN + touches_exact_one := EXISTS ( + SELECT 1 + FROM employment_employing_organization_record relation + WHERE relation.tenant_record_id = v_tenant_record_id + AND relation.employing_organization_unit_id = v_organization_unit_id + ); + END IF; + ELSE + -- Every employer-relationship mutation changes the exact-one graph. + touches_exact_one := true; + END IF; + + IF touches_exact_one THEN + PERFORM pg_advisory_xact_lock( + hashtext('orgmetra.employment_employer_exact_one'), + hashtext(v_tenant_record_id::text) + ); + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER employment_employing_organization_invariant_employment_lock +BEFORE INSERT OR UPDATE OR DELETE ON employment_record_version +FOR EACH ROW +EXECUTE FUNCTION serialize_employment_employing_organization_invariant(); + +CREATE TRIGGER employment_employing_organization_invariant_relationship_lock +BEFORE INSERT OR UPDATE OR DELETE ON employment_employing_organization_record +FOR EACH ROW +EXECUTE FUNCTION serialize_employment_employing_organization_invariant(); + +CREATE TRIGGER employment_employing_organization_invariant_organization_lock +BEFORE INSERT OR UPDATE OR DELETE ON organization_unit_version +FOR EACH ROW +EXECUTE FUNCTION serialize_employment_employing_organization_invariant(); + +CREATE FUNCTION validate_employment_employing_organization_scope() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + requested_effective_range daterange; + legal_entity_coverage datemultirange; + employment_coverage datemultirange; +BEGIN + requested_effective_range := daterange(NEW.effective_from, NEW.effective_to, '[)'); + + -- Missing/cross-tenant organization anchors are left to the tenant-qualified + -- foreign key so callers receive the structural integrity failure rather + -- than a misleading classification error. + IF NOT EXISTS ( + SELECT 1 + FROM organization_unit + WHERE tenant_record_id = NEW.tenant_record_id + AND organization_unit_id = NEW.employing_organization_unit_id + ) THEN + RETURN NEW; + END IF; + + SELECT range_agg(daterange(effective_from, effective_to, '[)')) + INTO legal_entity_coverage + FROM organization_unit_version + WHERE tenant_record_id = NEW.tenant_record_id + AND organization_unit_id = NEW.employing_organization_unit_id + AND organization_type_code = 'legal_entity' + AND tstzrange(recorded_from, recorded_to, '[)') @> NEW.recorded_from; + + IF legal_entity_coverage IS NULL + OR NOT (legal_entity_coverage @> requested_effective_range) THEN + RAISE EXCEPTION 'employing organization must be a legal_entity over the full effective interval at the recorded-time coordinate' + USING ERRCODE = '23514'; + END IF; + + SELECT range_agg(daterange(effective_from, effective_to, '[)')) + INTO employment_coverage + FROM employment_record_version + WHERE tenant_record_id = NEW.tenant_record_id + AND employment_record_id = NEW.employment_record_id + AND employment_status_code IN ('active', 'leave') + AND tstzrange(recorded_from, recorded_to, '[)') @> NEW.recorded_from; + + IF employment_coverage IS NULL + OR NOT (employment_coverage @> requested_effective_range) THEN + RAISE EXCEPTION 'employing organization interval must be covered by active or leave Employment truth at the recorded-time coordinate' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER employment_employing_organization_scope_guard +BEFORE INSERT ON employment_employing_organization_record +FOR EACH ROW +EXECUTE FUNCTION validate_employment_employing_organization_scope(); + +CREATE FUNCTION employment_employing_organization_exact_one_is_valid( + target_tenant_record_id uuid, + target_employment_record_id uuid +) +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ +WITH active_employment_versions AS ( + SELECT effective_from, effective_to, recorded_from, recorded_to + FROM employment_record_version + WHERE tenant_record_id = target_tenant_record_id + AND employment_record_id = target_employment_record_id + AND employment_status_code IN ('active', 'leave') +), relationship_rows AS ( + SELECT effective_from, effective_to, recorded_from, recorded_to, + employing_organization_unit_id + FROM employment_employing_organization_record + WHERE tenant_record_id = target_tenant_record_id + AND employment_record_id = target_employment_record_id +), recorded_points AS ( + SELECT recorded_from AS recorded_point FROM active_employment_versions + UNION + SELECT recorded_to FROM active_employment_versions WHERE recorded_to IS NOT NULL + UNION + SELECT recorded_from FROM relationship_rows + UNION + SELECT recorded_to FROM relationship_rows WHERE recorded_to IS NOT NULL + UNION + SELECT organization_version.recorded_from + FROM organization_unit_version organization_version + WHERE organization_version.tenant_record_id = target_tenant_record_id + AND organization_version.organization_unit_id IN ( + SELECT employing_organization_unit_id FROM relationship_rows + ) + UNION + SELECT organization_version.recorded_to + FROM organization_unit_version organization_version + WHERE organization_version.tenant_record_id = target_tenant_record_id + AND organization_version.organization_unit_id IN ( + SELECT employing_organization_unit_id FROM relationship_rows + ) + AND organization_version.recorded_to IS NOT NULL +) +SELECT NOT EXISTS ( + SELECT 1 + FROM active_employment_versions employment_version + JOIN recorded_points point + ON tstzrange( + employment_version.recorded_from, + employment_version.recorded_to, + '[)' + ) @> point.recorded_point + WHERE NOT EXISTS ( + SELECT 1 + FROM ( + SELECT range_agg( + daterange( + relationship.effective_from, + relationship.effective_to, + '[)' + ) + ) AS covered_effective_period + FROM relationship_rows relationship + WHERE tstzrange( + relationship.recorded_from, + relationship.recorded_to, + '[)' + ) @> point.recorded_point + AND EXISTS ( + SELECT 1 + FROM ( + SELECT range_agg( + daterange( + organization_version.effective_from, + organization_version.effective_to, + '[)' + ) + ) AS legal_entity_effective_period + FROM organization_unit_version organization_version + WHERE organization_version.tenant_record_id = target_tenant_record_id + AND organization_version.organization_unit_id = relationship.employing_organization_unit_id + AND organization_version.organization_type_code = 'legal_entity' + AND tstzrange( + organization_version.recorded_from, + organization_version.recorded_to, + '[)' + ) @> point.recorded_point + ) legal_entity + WHERE legal_entity.legal_entity_effective_period @> daterange( + relationship.effective_from, + relationship.effective_to, + '[)' + ) + ) + ) relationship_coverage + WHERE relationship_coverage.covered_effective_period @> daterange( + employment_version.effective_from, + employment_version.effective_to, + '[)' + ) + ) +); +$$; + +CREATE FUNCTION enforce_employment_employing_organization_exact_one() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_tenant_record_id uuid; + v_employment_record_id uuid; + v_organization_unit_id uuid; + old_tenant_record_id uuid; + old_organization_unit_id uuid; + affected_tenant_record_id uuid; + affected_employment_record_id uuid; +BEGIN + IF TG_TABLE_NAME IN ( + 'employment_record_version', + 'employment_employing_organization_record' + ) THEN + IF TG_OP = 'DELETE' THEN + v_tenant_record_id := OLD.tenant_record_id; + v_employment_record_id := OLD.employment_record_id; + ELSE + v_tenant_record_id := NEW.tenant_record_id; + v_employment_record_id := NEW.employment_record_id; + END IF; + + IF NOT employment_employing_organization_exact_one_is_valid( + v_tenant_record_id, + v_employment_record_id + ) THEN + RAISE EXCEPTION 'active or leave Employment must have exactly one legal employer across every effective and recorded-time coordinate' + USING ERRCODE = '23514'; + END IF; + + IF TG_OP = 'UPDATE' + AND ( + OLD.tenant_record_id IS DISTINCT FROM NEW.tenant_record_id + OR OLD.employment_record_id IS DISTINCT FROM NEW.employment_record_id + ) + AND NOT employment_employing_organization_exact_one_is_valid( + OLD.tenant_record_id, + OLD.employment_record_id + ) THEN + RAISE EXCEPTION 'active or leave Employment must have exactly one legal employer across every effective and recorded-time coordinate' + USING ERRCODE = '23514'; + END IF; + ELSE + IF TG_OP = 'DELETE' THEN + v_tenant_record_id := OLD.tenant_record_id; + v_organization_unit_id := OLD.organization_unit_id; + ELSE + v_tenant_record_id := NEW.tenant_record_id; + v_organization_unit_id := NEW.organization_unit_id; + END IF; + + IF TG_OP = 'UPDATE' THEN + old_tenant_record_id := OLD.tenant_record_id; + old_organization_unit_id := OLD.organization_unit_id; + END IF; + + FOR affected_tenant_record_id, affected_employment_record_id IN + SELECT DISTINCT relation.tenant_record_id, relation.employment_record_id + FROM employment_employing_organization_record relation + WHERE ( + relation.tenant_record_id = v_tenant_record_id + AND relation.employing_organization_unit_id = v_organization_unit_id + ) + OR ( + TG_OP = 'UPDATE' + AND relation.tenant_record_id = old_tenant_record_id + AND relation.employing_organization_unit_id = old_organization_unit_id + ) + LOOP + IF NOT employment_employing_organization_exact_one_is_valid( + affected_tenant_record_id, + affected_employment_record_id + ) THEN + RAISE EXCEPTION 'active or leave Employment must have exactly one legal employer across every effective and recorded-time coordinate' + USING ERRCODE = '23514'; + END IF; + END LOOP; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +CREATE CONSTRAINT TRIGGER employment_employing_organization_exact_one_employment_guard +AFTER INSERT OR UPDATE OR DELETE ON employment_record_version +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION enforce_employment_employing_organization_exact_one(); + +CREATE CONSTRAINT TRIGGER employment_employing_organization_exact_one_relationship_guard +AFTER INSERT OR UPDATE OR DELETE ON employment_employing_organization_record +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION enforce_employment_employing_organization_exact_one(); + +CREATE CONSTRAINT TRIGGER employment_employing_organization_exact_one_organization_guard +AFTER INSERT OR UPDATE OR DELETE ON organization_unit_version +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION enforce_employment_employing_organization_exact_one(); + +CREATE TRIGGER employment_employing_organization_bitemporal_guard +BEFORE UPDATE OR DELETE ON employment_employing_organization_record +FOR EACH ROW +EXECUTE FUNCTION protect_bitemporal_history(); + +CREATE FUNCTION reject_employment_employing_organization_truncate() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'employment employing-organization history cannot be truncated' + USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER employment_employing_organization_truncate_guard +BEFORE TRUNCATE ON employment_employing_organization_record +FOR EACH STATEMENT +EXECUTE FUNCTION reject_employment_employing_organization_truncate(); + +ALTER TABLE employment_employing_organization_record ENABLE ROW LEVEL SECURITY; +ALTER TABLE employment_employing_organization_record FORCE ROW LEVEL SECURITY; +CREATE POLICY employment_employing_organization_scope_policy +ON employment_employing_organization_record +USING (tenant_record_id = current_tenant_record_id()) +WITH CHECK (tenant_record_id = current_tenant_record_id()); + +REVOKE ALL ON FUNCTION serialize_employment_employing_organization_invariant() FROM PUBLIC; +REVOKE ALL ON FUNCTION validate_employment_employing_organization_scope() FROM PUBLIC; +REVOKE ALL ON FUNCTION reject_employment_employing_organization_truncate() FROM PUBLIC; + +COMMENT ON TABLE employment_employing_organization_record IS + 'Bitemporal tenant-scoped legal-employer relationship for one Employment; distinct from Position and Assignment and corrected by closing recorded time rather than rewriting history.'; +COMMENT ON COLUMN employment_employing_organization_record.employing_organization_unit_id IS + 'Organization unit that is legal_entity throughout this effective interval at the row recorded-time coordinate.'; diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 27235d9c9..1dcd127f7 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -19,6 +19,12 @@ The baseline scope contract is: Scopes are coarse API capabilities. A caller-supplied business purpose remains a finer authorization input and cannot enlarge a token's scope or authorize itself. +Employment creation and confirmed-hire materialization additionally require the +exact employing-organization target scope +`orgmetra.people.write.organization_unit_` in the +authenticated token. The target scope is checked alongside the operation scope; +an organization identifier in the request body is never permission by itself. + ## Command requirements Every mutating request requires: @@ -41,6 +47,8 @@ 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 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. ## Example endpoints @@ -73,4 +81,4 @@ The foundation OpenAPI contract covers the shared command vocabulary and baselin } ``` -`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. \ No newline at end of file +`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 7d4afd563..4674dd01b 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -8,6 +8,7 @@ | `person_record` | Durable person entity inside Orgmetra, not an authentication subject. | | `employment_record` | Durable employment identity for a person. | | `employment_record_version` | Bitemporal employment status, exclusive-or-concurrent code, and effective period. | +| `employment_employing_organization_record` | Bitemporal, tenant-qualified relationship that records an Employment's legal employing Organization independently of Position and Assignment. | | `organization_unit` | Durable organizational identity referenced by positions and hierarchy facts. | | `organization_unit_version` | Bitemporal organizational name, type, and parent relationship for an organization unit. | | `job_profile` | Durable job identity referenced by positions, criteria, and decisions. | @@ -52,6 +53,8 @@ Intervals are half-open and non-empty: an end value, when present, must be stric Durable anchors such as `organization_unit`, `job_profile`, `employment_record`, and `position_record` do not repeat mutable descriptive attributes. Their descriptive versions live in `organization_unit_version`, `job_profile_version`, `employment_record_version`, and `position_record_version`. Single-valued bitemporal version families reject overlapping effective/system intervals, so one `effective_from`/`effective_to` interval combined with one `recorded_from`/`recorded_to` interval cannot yield contradictory current descriptions. Corrections close the previous recorded interval and insert a replacement; in-place business mutation is rejected. +`employment_employing_organization_record` is a separate bitemporal Employment-to-Organization fact. Every `active` or `leave` Employment coordinate has exactly one employing Organization, never more than one, with deferred database validation across effective and recorded-time changes. The Organization must be a `legal_entity`, and the relationship interval must be covered by `active` or `leave` Employment truth. Position placement and Assignment allocation do not determine legal-employer identity. + Assignments remain a legitimately multiple-membership fact. Each assignment must name the covering employment and the same person as that employment. Exclusive employments for one person cannot overlap; a second job must be marked `concurrent`. Allocation totals for one employment, and visible allocations for one position, are enforced by `orgmetra_hris_kernel` rather than a single-valued exclusion. An assignment day must also land on an `active` or `open` position version. ## High-impact decision evidence diff --git a/docs/ERD.md b/docs/ERD.md index a547cc3a0..51592a39f 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -12,7 +12,9 @@ erDiagram person_record ||--o{ person_name_record : has_names person_record ||--o{ employment_record : has employment_record ||--o{ employment_record_version : has_versions + employment_record ||--o{ employment_employing_organization_record : has_employer_history organization_unit ||--o{ organization_unit_version : has_versions + organization_unit ||--o{ employment_employing_organization_record : employs organization_unit_version }o--o| organization_unit : may_parent organization_unit ||--o{ position_record : contains job_profile ||--o{ job_profile_version : has_versions @@ -49,6 +51,8 @@ erDiagram `organization_unit`, `job_profile`, `employment_record`, and `position_record` are durable anchors. Mutable names, classifications, parent relationships, titles, families, version codes, and employment or position status live in bitemporal version rows. Positions retain stable organization/job references while retroactive corrections append or supersede version facts rather than rewriting identity. An organization version may reference another durable organization as its parent; self-parenting is rejected at the database boundary. An assignment names the employment that covers it, so a person cannot be assigned through another worker's employment. Exclusive employment versions for one person cannot overlap. An assignment day must land on an `active` or `open` position version, and visible allocations for one seat cannot exceed 1.0000. +`employment_employing_organization_record` is the independent legal-employer relationship. It is bitemporal and tenant-qualified, requires exactly one employer for every `active` or `leave` Employment effective/system coordinate with no effective gaps, and rejects overlaps through the bitemporal exclusion. Deferred database validation also follows Employment, relationship, and legal-organization recorded-time changes. The employer requires `legal_entity` classification across the full effective interval and `active` or `leave` Employment coverage. A Position or Assignment therefore cannot silently substitute for legal-employer truth. + Every owned HRIS fact carries `tenant_record_id`. Relationships that cross table boundaries use tenant-qualified foreign keys, and row-level security independently filters every tenant-scoped relation. The tenant column is therefore both a referential-integrity boundary and a runtime isolation boundary, not a caller-supplied business attribute. A candidate profile can be linked to at most one worker identity within its tenant. A person identity can have multiple candidate-worker links across reapplications or historical candidate profiles, so the person-side cardinality is one-to-many. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fd6dd3ea6..522f0308b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -33,9 +33,9 @@ Orgmetra evaluates PII access before protected field values leave the authoritative HR boundary. Keyverse supplies authenticated identity and scope attributes through its published contract; Orgmetra owns the HR authorization policy and decision. -Every `PurposeBoundAccessRequest` must bind the active request tenant, authenticated actor tenant, target resource tenant, opaque actor reference, opaque target-resource reference, resource kind, purpose, operation, requested field names, and authenticated scope set. The target reference must identify the exact Orgmetra record with an opaque namespaced value suitable for audit correlation; it must not encode protected HR field values. The matching `PurposeBoundAccessPolicy` binds one tenant and immutable policy version to exactly one resource kind, purpose, operation, required Orgmetra scope, and permitted field set. There is no wildcard policy form. +Every `PurposeBoundAccessRequest` must bind the active request tenant, authenticated actor tenant, target resource tenant, opaque actor reference, opaque target-resource reference, resource kind, purpose, operation, requested field names, and authenticated scope set. A mutation may also carry an exact target scope, such as the employing organization scope, which must be present in that authenticated scope set. The target reference must identify the exact Orgmetra record with an opaque namespaced value suitable for audit correlation; it must not encode protected HR field values. The matching `PurposeBoundAccessPolicy` binds one tenant and immutable policy version to exactly one resource kind, purpose, operation, required Orgmetra scope, and permitted field set. There is no wildcard policy form. -Evaluation fails closed unless request, actor, resource, and policy tenants all agree; resource, purpose, and operation exactly match; the required operation-specific scope is present; and requested fields are a non-empty subset of permitted fields. UUID sentinels, malformed opaque actor or target references, wildcard-like codes, malformed scopes, empty sets, and mutable field/scope collections are rejected before evaluation. A valid purpose header cannot compensate for a missing scope or foreign resource tenant. +Evaluation fails closed unless request, actor, resource, and policy tenants all agree; resource, purpose, and operation exactly match; the required operation-specific scope and any exact target scope are present; and requested fields are a non-empty subset of permitted fields. UUID sentinels, malformed opaque actor or target references, wildcard-like codes, malformed scopes, empty sets, and mutable field/scope collections are rejected before evaluation. A valid purpose header cannot compensate for a missing scope or foreign resource tenant. Authorization evidence contains only governance metadata, including the opaque actor and exact target-resource references, plus field names, never protected values. A denial returns a stable reason code and next safe action. An allow decision returns only the exact requested field subset, not every field the policy could permit. Both allow and denial evidence preserve the exact target reference so immutable audit correlation cannot collapse distinct person or employment records into one resource-kind-level event. These rules implement the Orgmetra side of the NIST SP 800-162 ABAC shape and attribute-integrity principles from NIST SP 800-205; ADR 0008 records the boundary. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 22a4178fe..021288d05 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -9,6 +9,7 @@ | Tenant-qualified HRIS integrity and fail-closed isolation | Core bounded contexts / Security architecture | `tenant_record`, tenant-qualified foreign keys, forced row-level security policies, tenant-scoped kernel query parameters | PostgreSQL cross-tenant FK/application-role RLS contracts plus kernel cross-tenant reconstruction, employment coverage, position coverage, seat-capacity, portfolio, exclusivity, and organization-hierarchy regressions | ADR-0001, ADR-0003 | implemented_on_active_pr | | Reserved UUID sentinel exclusion | Persistence integrity boundary | every foundation UUID `*_id` column plus audit/outbox identifiers | PostgreSQL inventory proof plus Nil/Max foundation and audit/outbox persistence regressions | ADR-0001, RFC 9562 | implemented_on_active_pr | | Normalized bitemporal organization/job/employment/position history | Core bounded contexts | `organization_unit_version`, `job_profile_version`, `employment_record_version`, `position_record_version` | PostgreSQL non-overlap, concurrent conflict, correction, rewrite-rejection, assignment-employment binding, and single-valued historical reconstruction | ADR-0001, ADR-0003, ADR-0004 | implemented_on_active_pr | +| Authoritative employing legal-organization relationship | Employment / Organization core | `employment_employing_organization_record` plus tenant-scoped exact-one serialization lock across relevant Employment/Organization mutations | `test_employment_employing_organization_postgres.sh` proves exactly one legal employer for every active/leave Employment coordinate, rejects missing relationships and effective gaps, proves legal-entity and active/leave coverage, deterministically blocks the concurrent legal-entity-reclassification/active-Employment write-skew window before deferred validation, and proves correction history, tenant-qualified integrity, and forced-RLS isolation | ADR-0141 | implemented_on_active_pr | | Acyclic organization hierarchy at historical coordinates | Organization core | `organization_unit_version.parent_organization_unit_id` | indirect A→B→C→A rejection plus future-recorded and foreign-tenant isolation in `orgmetra_hris_kernel` | ADR-0001, ADR-0003 | implemented_on_protected_main | | Effective/system time | Bitemporal HRIS | `effective_from`, `recorded_from` | strict half-open interval and historical-coordinate tests | ADR-0003 | implemented_on_active_pr | | 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 | diff --git a/docs/adr/0141-employment-employing-organization.md b/docs/adr/0141-employment-employing-organization.md new file mode 100644 index 000000000..80c51083d --- /dev/null +++ b/docs/adr/0141-employment-employing-organization.md @@ -0,0 +1,61 @@ +# ADR-0141: Employment employing-organization truth + +- Status: proposed_on_active_pr +- Date: 2026-08-28 +- Owner: Orgmetra HRIS core +- Scope: Employment / Organization + +## Context + +Protected `develop` models Person, Employment, Organization, Job, Position, and Assignment as distinct concepts, but Employment itself has no authoritative link to the organization that is the legal employer. Inferring legal employer from Position is unsafe: a Position move need not change the employing legal entity, and a legal-employer transfer need not imply the same Position semantics. + +For commercial HRIS use, the employer relationship must therefore be a first-class temporal HR fact, not a denormalized Position attribute or an application-only lookup. + +ISO 30414:2025 is the current published second edition of the human-capital reporting/disclosure standard and includes workforce composition among its core reporting areas. That supports the need for defensible organizational scope in workforce reporting; it does **not** prescribe this schema or authorize statutory/payroll interpretation. + +## Decision + +Add `employment_employing_organization_record` as a 3NF bitemporal relationship between one tenant-scoped Employment and one tenant-scoped Organization. + +At every effective-date/system-recorded-time coordinate where Employment status is `active` or `leave`, exactly one employing organization must resolve: overlaps are rejected by the bitemporal exclusion and missing relationships or effective gaps are rejected by deferred database constraint triggers. The organization must be classified as `legal_entity` across the relationship's full effective interval at the relationship's recorded-time coordinate. The relationship interval must also be fully covered by `active` or `leave` Employment truth at that same recorded-time coordinate. + +Because the exact-one rule spans Employment versions, employer relationships, and Organization versions, deferred validation alone is insufficient under `READ COMMITTED`: two concurrent transactions can otherwise validate snapshots that exclude each other's uncommitted changes. Before a mutation that can affect the invariant proceeds, Orgmetra therefore takes one tenant-scoped transaction advisory lock for active/leave Employment version changes, every employer-relationship mutation, and legal-entity or already-referenced Organization-version changes. The lock serializes only the integrity-changing transaction class; deferred triggers still perform the authoritative cross-table validation at commit after any competing transaction becomes visible. The lock is an integrity mechanism, not an authorization mechanism, and a hash collision may only add harmless serialization rather than permit invalid state. + +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. + +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 + +- Workforce and employment-contract scope can distinguish legal employer from seat/organization placement. +- Position transfers do not silently rewrite employer identity. +- Legal-employer corrections preserve what Orgmetra knew and when. +- Concurrent employer-classification, relationship, and active/leave Employment mutations for one tenant serialize before exact-one validation, preventing READ COMMITTED write skew. +- Tenant-local bulk Employment/employer migrations must account for this integrity lock in batching and lock-wait observability; any future reduction in lock granularity must preserve the concurrency regression before adoption. +- The model remains usable without payroll or statutory-account ownership. +- Mutation APIs additionally require purpose-bound authorization, accountable actor/reason/evidence, immutable audit/outbox, and idempotency; persistence integrity alone does not authorize a high-impact Employment change. + +## Alternatives rejected + +1. **Infer employer from Position.organization_unit_id.** Rejected because seat ownership and legal employment are different facts and can change independently. +2. **Store employer directly on `employment_record`.** Rejected because it would erase effective/system history and conflate durable Employment identity with a mutable relationship. +3. **Store a free-text legal-employer code.** Rejected because it breaks tenant-qualified Organization integrity and workforce lineage. +4. **Rely on deferred cross-table triggers without write serialization.** Rejected because concurrent `READ COMMITTED` transactions can validate before either commit becomes visible and leave a write-skewed final state. +5. **Add payroll/legal-accounting tables.** Rejected as outside this bounded HRIS slice and outside accepted owner contracts. + +## Verification + +`tests/test_employment_employing_organization_postgres.sh` proves: + +- exactly one employer per active/leave Employment business/system coordinate, including missing and effective-gap rejection; +- full-interval `legal_entity` classification; +- full-interval active/leave Employment coverage; +- deterministic serialization of a legal-entity reclassification against a competing active Employment/employer write before deferred validation; +- tenant-qualified cross-tenant rejection; +- correction-not-rewrite history; +- TRUNCATE protection; and +- forced-RLS isolation under a `NOSUPERUSER NOBYPASSRLS` application role. + +## References + +International Organization for Standardization. (2025). *ISO 30414:2025 Human resource management — Requirements and recommendations for human capital reporting and disclosure* (2nd ed.). ISO. diff --git a/docs/adr/README.md b/docs/adr/README.md index 099a21139..a57939cc6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,3 +16,4 @@ | [0012](0012-governed-migration-handoff.md) | Governed migration handoff | Accepted on active implementation branch | | [0013](0013-governed-requisition-review-packet.md) | Governed requisition review packet | Accepted on active implementation branch | | [0014](0014-job-analysis-snapshot-persistence.md) | Persist governed job-analysis snapshots | Accepted on active implementation branch | +| [0141](0141-employment-employing-organization.md) | Employment employing-organization truth | proposed_on_active_pr | diff --git a/docs/doctoring/employment-employing-organization.md b/docs/doctoring/employment-employing-organization.md new file mode 100644 index 000000000..6b5f4ac23 --- /dev/null +++ b/docs/doctoring/employment-employing-organization.md @@ -0,0 +1,25 @@ +# Employment employing-organization evidence note + +## Question + +Why does Orgmetra model an Employment's employing legal organization separately from Position and Assignment? + +## Current authoritative standard evidence + +ISO 30414:2025 is the published second edition of *Human resource management — Requirements and recommendations for human capital reporting and disclosure*. ISO lists workforce composition among the standard's core human-capital reporting areas and states that the standard is intended to support comprehensive internal and external human-capital reporting across organizations of different sizes and sectors. + +Orgmetra uses that published standard only as reporting-context evidence that workforce composition needs defensible organizational scope. ISO 30414 does not define this database schema and does not by itself determine the legal employer for any jurisdiction. + +## Product implication + +A Position is an organizational seat. An Employment is the durable worker-employment relationship. Treating `position_record.organization_unit_id` as the legal employer would therefore make a seat transfer silently change employment-contract scope and would make a legal-employer transfer indistinguishable from a seat move. + +The active PR instead records a tenant-scoped, bitemporal `employment_employing_organization_record` relationship. Every active/leave Employment effective/system coordinate must have exactly one such legal employer, with deferred database checks rejecting missing relationships, effective gaps, and changes that invalidate recorded-time coverage. The referenced Organization must be `legal_entity` over the full effective interval at the row's recorded-time coordinate, while the same interval must be covered by active/leave Employment truth. No payroll, withholding, tax, statutory-accounting, benefits, compensation, candidate, performance, or model-output fields are added. + +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. + +This is an HRIS source-of-truth relationship, not a claim of payroll or statutory-system ownership. + +## Reference (APA 7) + +International Organization for Standardization. (2025). *ISO 30414:2025 Human resource management — Requirements and recommendations for human capital reporting and disclosure* (2nd ed.). ISO. https://www.iso.org/standard/30414 diff --git a/manifest.json b/manifest.json index 97f2bab14..9c6809f82 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"32cc4ef78d1eca557fa01731026840be01211a043eb0ada552e4e6cb9eace353","bytes":17295,"lines":76},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac","bytes":11185,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8","bytes":16534,"lines":135},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e","bytes":11462,"lines":40},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002","bytes":1838,"lines":18},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"595e8381dbd62e97093b11eef818af5f04d6473ac592d57e3985ffbc2210d445","bytes":28173,"lines":689},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"918cf92fd18d81572e9bd5f5daa7f033c32731e2e13f0d00661d1c1de30b12a9","bytes":27291,"lines":638}]} +{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"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 diff --git a/package.json b/package.json index ceb2fb8fc..e15e862a8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "description": "Orgmetra evidence-centered HRIS foundation baseline.", "scripts": { - "validate": "python3 tests/validate_repository.py && node scripts/foundation-contract.mjs && node --test tests/foundation-contract.test.mjs tests/openapi-contract.test.mjs tests/dispatcher-inventory.test.mjs" + "validate:employment-employer-contract": "python3 -c \"from pathlib import Path; import re; sql=Path('database/migrations/0040_employment_employing_organization.sql').read_text(encoding='utf-8'); required=('CREATE TABLE employment_employing_organization_record','employment_employing_organization_employment_tenant_fk','employment_employing_organization_unit_tenant_fk','employment_employing_organization_bitemporal_exclusion','employment_employing_organization_exact_one_relationship_guard','employment_employing_organization_exact_one_organization_guard','employment_employing_organization_bitemporal_guard','employment_employing_organization_truncate_guard','ALTER TABLE employment_employing_organization_record FORCE ROW LEVEL SECURITY','employment_employing_organization_scope_policy','employment_employing_organization_unit_lookup_index'); missing=[item for item in required if item not in sql]; assert not missing, f'Missing Employment employing-organization contract fragments: {missing}'; identifiers=re.findall(r'\\bCONSTRAINT\\s+([a-z_][a-z0-9_]*)', sql, flags=re.IGNORECASE); too_long=[name for name in identifiers if len(name.encode('utf-8')) > 63]; assert not too_long, f'PostgreSQL constraint identifiers exceed 63 bytes and would be truncated: {too_long}'\"", + "validate": "npm run validate:employment-employer-contract && python3 tests/validate_repository.py && node scripts/foundation-contract.mjs && node --test tests/foundation-contract.test.mjs tests/openapi-contract.test.mjs tests/dispatcher-inventory.test.mjs" } } diff --git a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py index b1a5f92c2..7e1121585 100644 --- a/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py +++ b/packages/keyverse-adapter/src/orgmetra_keyverse_adapter/authorization.py @@ -142,9 +142,11 @@ class PurposeBoundAccessRequest: ``tenant_record_id`` is the active Orgmetra request context, and ``resource_tenant_record_id`` comes from the target record identity. The opaque ``resource_reference`` identifies that exact target for audit - correlation without copying its PII. All tenant identifiers must match the - policy tenant. Only field names are carried here; field values remain behind - the authoritative data boundary until access is allowed. + correlation without copying its PII. An optional target scope narrows the + operation to one exact organization or other governed target. All tenant + identifiers must match the policy tenant. Only field names are carried here; + field values remain behind the authoritative data boundary until access is + allowed. """ tenant_record_id: UUID @@ -157,6 +159,7 @@ class PurposeBoundAccessRequest: resource_kind: str requested_fields: frozenset[str] granted_scope_codes: frozenset[str] + required_target_scope_code: str | None = None def __post_init__(self) -> None: """Reject untrusted identity, target, tenant, purpose, field, or scope attributes.""" @@ -174,6 +177,8 @@ def __post_init__(self) -> None: _validate_code("operation_code", self.operation_code) _validate_field_set("requested_fields", self.requested_fields) _validate_scope_set(self.granted_scope_codes) + if self.required_target_scope_code is not None: + _validate_scope("required_target_scope_code", self.required_target_scope_code) @dataclass(frozen=True, slots=True) @@ -192,6 +197,12 @@ class AuthorizationDecision: authorized_fields: frozenset[str] reason_code: str next_action: str + required_target_scope_code: str | None = None + + def __post_init__(self) -> None: + """Reject malformed governed-target evidence on manually constructed decisions.""" + if self.required_target_scope_code is not None: + _validate_scope("required_target_scope_code", self.required_target_scope_code) class AuthorizationDeniedError(PermissionError): @@ -211,13 +222,18 @@ def _decision( policy: PurposeBoundAccessPolicy, allowed: bool, reason_code: str, + next_action_override: str | None = None, ) -> AuthorizationDecision: """Build one immutable allow/deny record without copying protected values.""" authorized_fields = request.requested_fields if allowed else frozenset() next_action = ( "Continue with only the authorized fields." if allowed - else _DENIAL_NEXT_ACTION[reason_code] + else ( + next_action_override + if next_action_override is not None + else _DENIAL_NEXT_ACTION[reason_code] + ) ) return AuthorizationDecision( allowed=allowed, @@ -232,6 +248,7 @@ def _decision( authorized_fields=authorized_fields, reason_code=reason_code, next_action=next_action, + required_target_scope_code=request.required_target_scope_code, ) @@ -286,6 +303,20 @@ def evaluate_purpose_bound_access( allowed=False, reason_code="required_scope_missing", ) + if ( + request.required_target_scope_code is not None + and request.required_target_scope_code not in request.granted_scope_codes + ): + return _decision( + request=request, + policy=policy, + allowed=False, + reason_code="required_scope_missing", + next_action_override=( + "Obtain the exact Keyverse scope for the governed target before retrying; " + "the operation scope alone cannot authorize that target." + ), + ) if not request.requested_fields.issubset(policy.permitted_fields): return _decision( request=request, diff --git a/packages/keyverse-adapter/tests/test_authorization.py b/packages/keyverse-adapter/tests/test_authorization.py index 77f4ea701..2a00fe2fb 100644 --- a/packages/keyverse-adapter/tests/test_authorization.py +++ b/packages/keyverse-adapter/tests/test_authorization.py @@ -53,6 +53,35 @@ def test_exact_tenant_purpose_scope_and_field_subset_is_authorized() -> None: assert decision.next_action == "Continue with only the authorized fields." +def test_exact_target_scope_is_authorized() -> None: + """Allow a request only when its additional target scope is explicitly granted.""" + target_scope = "orgmetra.people.write.organization_unit_0198a412820070008000000000000050" + request = replace( + REQUEST, + granted_scope_codes=REQUEST.granted_scope_codes | {target_scope}, + required_target_scope_code=target_scope, + ) + + decision = require_purpose_bound_access(request=request, policy=POLICY) + + assert decision.allowed is True + + +def test_missing_exact_target_scope_is_denied() -> None: + """Deny an otherwise valid request when its exact target scope is absent.""" + request = replace( + REQUEST, + required_target_scope_code=( + "orgmetra.people.write.organization_unit_0198a412820070008000000000000050" + ), + ) + + decision = evaluate_purpose_bound_access(request=request, policy=POLICY) + + assert decision.allowed is False + assert decision.reason_code == "required_scope_missing" + + @pytest.mark.parametrize( ("access_request", "reason_code", "next_action"), [ diff --git a/packages/keyverse-adapter/tests/test_target_scope_denial_guidance.py b/packages/keyverse-adapter/tests/test_target_scope_denial_guidance.py new file mode 100644 index 000000000..c8eee092e --- /dev/null +++ b/packages/keyverse-adapter/tests/test_target_scope_denial_guidance.py @@ -0,0 +1,46 @@ +"""Regression coverage for target-specific authorization denial guidance.""" + +from uuid import UUID + +from orgmetra_keyverse_adapter.authorization import ( + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + evaluate_purpose_bound_access, +) + + +def test_missing_target_scope_tells_operator_to_obtain_target_scope() -> None: + """A target-scope denial must not mislabel the missing grant as operation scope.""" + tenant = UUID("10000000-0000-7000-8000-000000000501") + target_scope = "orgmetra.people.write.organization_unit_0198a412820070008000000000000050" + policy = PurposeBoundAccessPolicy( + tenant_record_id=tenant, + policy_version_code="people_write_v1", + resource_kind="employment_record", + purpose_code="hr_operations", + operation_code="create_employment", + required_scope_code="orgmetra.people.write", + permitted_fields=frozenset({"employing_organization_unit_id"}), + ) + request = PurposeBoundAccessRequest( + tenant_record_id=tenant, + actor_tenant_record_id=tenant, + resource_tenant_record_id=tenant, + actor_reference="keyverse_subject:sub_operator", + resource_reference="employment_record:emp_target", + purpose_code="hr_operations", + operation_code="create_employment", + resource_kind="employment_record", + requested_fields=frozenset({"employing_organization_unit_id"}), + granted_scope_codes=frozenset({"orgmetra.people.write"}), + required_target_scope_code=target_scope, + ) + + decision = evaluate_purpose_bound_access(request=request, policy=policy) + + assert decision.allowed is False + assert decision.reason_code == "required_scope_missing" + assert decision.next_action == ( + "Obtain the exact Keyverse scope for the governed target before retrying; " + "the operation scope alone cannot authorize that target." + ) diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 0fd397e92..7ae9e61f7 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -525,6 +525,7 @@ components: type: object additionalProperties: false required: + - employing_organization_unit_id - person_record_id - employment_status_code - employment_concurrency_code @@ -533,6 +534,9 @@ components: - confirmation_reference - evidence_references properties: + employing_organization_unit_id: + type: string + format: uuid person_record_id: type: string format: uuid diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 1e9fb267c..f2c538c08 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -52,7 +52,9 @@ export const REQUIRED_FILES = Object.freeze([ 'docs/adr/0012-governed-migration-handoff.md', 'docs/adr/0013-governed-requisition-review-packet.md', 'docs/adr/0014-job-analysis-snapshot-persistence.md', + 'docs/adr/0141-employment-employing-organization.md', 'docs/doctoring/REFERENCES.md', + 'docs/doctoring/employment-employing-organization.md', 'docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md', 'docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md', 'database/migrations/0001_foundation_schema.sql', @@ -68,6 +70,7 @@ export const REQUIRED_FILES = Object.freeze([ 'database/migrations/0011_criterion_observation_scope.sql', 'database/migrations/0012_people_mutation_idempotency.sql', 'database/migrations/0013_job_analysis_snapshot.sql', + 'database/migrations/0040_employment_employing_organization.sql', 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', 'packages/hris-kernel/tests/test_audit_outbox.py', 'schemas/openapi.yaml', @@ -89,6 +92,7 @@ export const REQUIRED_FILES = Object.freeze([ 'tests/test_criterion_observation_scope_postgres.sh', 'tests/test_people_mutation_idempotency_postgres.sh', 'tests/test_job_analysis_snapshot_postgres.sh', + 'tests/test_employment_employing_organization_postgres.sh', 'tests/validate_repository.py' ]); @@ -107,6 +111,7 @@ export const MATURITY_VALUES = Object.freeze(new Set([ export const DATABASE_OBJECT_NAMES = Object.freeze([ 'tenant_record', 'person_record', 'person_name_record', 'person_contact_record', 'external_identity_link', 'employment_record', 'employment_record_version', + 'employment_employing_organization_record', 'employment_contract', 'employment_status_history', 'employment_transition', 'legal_entity', 'organization_unit', 'organization_relation', 'business_location', @@ -139,11 +144,12 @@ export const MIGRATION_BACKED_DATABASE_OBJECT_NAMES = Object.freeze([ 'job_analysis_task_item', 'job_analysis_ksao_item', 'job_analysis_task_ksao_link', - 'job_analysis_write_command' + 'job_analysis_write_command', + 'employment_employing_organization_record' ]); const UNFINISHED_MARKER_LINE_PATTERN = /^\s*(?:#{1,6}\s+|[-*+]\s+)?(?:\[(?:TODO|TBD|FIXME)\]|\{\{(?:TODO|TBD|FIXME)\}\}|<(?:TODO|TBD|FIXME)>|(?:TODO|TBD|FIXME)(?:\s*:\s*.*)?\s*)$/i; -const ADR_STATUS_PATTERN = /^\|\s*\[\d{4}\]\(([^)]+)\)\s*\|.*\|\s*(Proposed|Accepted|Superseded|Rejected)\s*\|$/; +const ADR_STATUS_PATTERN = /^\|\s*\[\d{4}\]\(([^)]+)\)\s*\|.*\|\s*(Proposed|Accepted|Superseded|Rejected|proposed_on_active_pr)\s*\|$/; const LOCAL_LINK_PATTERN = /\[[^\]]+\]\((?!https?:\/\/|mailto:|#)([^)]+)\)/g; const CREATE_TABLE_PATTERN = /\bCREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+(?:[a-z_][a-z0-9_]*\.)?([a-z_][a-z0-9_]*)/gi; const DOLLAR_QUOTE_START_PATTERN = /^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/; diff --git a/services/people-api/src/orgmetra_people_api/authorization.py b/services/people-api/src/orgmetra_people_api/authorization.py index cb066a88c..3448e26f7 100644 --- a/services/people-api/src/orgmetra_people_api/authorization.py +++ b/services/people-api/src/orgmetra_people_api/authorization.py @@ -13,6 +13,15 @@ from orgmetra_people_api.auth import AuthenticatedPrincipal +_MAX_UUID_INT = (1 << 128) - 1 + + +def organization_unit_scope_code(organization_unit_id: UUID) -> str: + """Return the exact Keyverse scope for one employing organization unit.""" + if not isinstance(organization_unit_id, UUID) or organization_unit_id.int in (0, _MAX_UUID_INT): + raise ValueError("organization_unit_id must be an operational UUID") + return f"orgmetra.people.write.organization_unit_{organization_unit_id.hex}" + def authorize_resource_fields( *, @@ -25,12 +34,14 @@ def authorize_resource_fields( resource_kind: str, requested_fields: frozenset[str], policy: PurposeBoundAccessPolicy, + required_target_scope_code: str | None = None, ) -> AuthorizationDecision: """Authorize one exact HR resource without duplicating Keyverse policy logic. The People API contributes only request-edge identity/scope attributes and the resolved target. The integrated Orgmetra adapter remains the single owner of tenant/resource/purpose/operation/scope/field evaluation and denial evidence. + ``required_target_scope_code`` narrows a valid operation to one exact target. """ request = PurposeBoundAccessRequest( tenant_record_id=tenant_record_id, @@ -43,5 +54,6 @@ def authorize_resource_fields( resource_kind=resource_kind, requested_fields=requested_fields, granted_scope_codes=principal.granted_scope_codes, + required_target_scope_code=required_target_scope_code, ) return require_purpose_bound_access(request=request, policy=policy) diff --git a/services/people-api/src/orgmetra_people_api/hire.py b/services/people-api/src/orgmetra_people_api/hire.py index 6823f4c59..ce3b22ee4 100644 --- a/services/people-api/src/orgmetra_people_api/hire.py +++ b/services/people-api/src/orgmetra_people_api/hire.py @@ -17,7 +17,7 @@ from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy from orgmetra_people_api.auth import AuthenticatedPrincipal -from orgmetra_people_api.authorization import authorize_resource_fields +from orgmetra_people_api.authorization import authorize_resource_fields, organization_unit_scope_code from orgmetra_people_api.mutations import validate_idempotency_key _MAX_UUID_INT = (1 << 128) - 1 @@ -52,12 +52,14 @@ class HireAcceptanceCommand: """ tenant_record_id: UUID + employing_organization_unit_id: UUID 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 candidate_worker_conversion_record_id: UUID audit_event_record_id: UUID outbox_delivery_record_id: UUID @@ -70,12 +72,14 @@ 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", @@ -162,6 +166,7 @@ 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), ) 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 3b1ddf461..cf6fbff7a 100644 --- a/services/people-api/src/orgmetra_people_api/hire_http.py +++ b/services/people-api/src/orgmetra_people_api/hire_http.py @@ -52,12 +52,14 @@ _SUPPORT_REFERENCE_RANDOM_BYTES = 24 _REQUIRED_BODY_KEYS = frozenset( { + "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", @@ -512,6 +514,9 @@ def _command_from_payload( effective_from = date.fromisoformat(effective_from_raw) return HireAcceptanceCommand( tenant_record_id=tenant_record_id, + employing_organization_unit_id=UUID( + _require_string_field(payload, "employing_organization_unit_id") + ), candidate_profile_id=UUID(_require_string_field(payload, "candidate_profile_id")), selection_decision_id=UUID(_require_string_field(payload, "selection_decision_id")), person_record_id=UUID(_require_string_field(payload, "person_record_id")), @@ -520,6 +525,9 @@ 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") + ), candidate_worker_conversion_record_id=UUID( _require_string_field(payload, "candidate_worker_conversion_record_id") ), 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 5158e9871..bbc1484cf 100644 --- a/services/people-api/src/orgmetra_people_api/mutation_http.py +++ b/services/people-api/src/orgmetra_people_api/mutation_http.py @@ -49,6 +49,7 @@ _MAX_UUID_INT = (1 << 128) - 1 _EMPLOYMENT_BODY_KEYS = frozenset( { + "employing_organization_unit_id", "person_record_id", "employment_status_code", "employment_concurrency_code", @@ -544,9 +545,13 @@ def _command_for_route( raise _InvalidHttpRequest("employment command fields are incomplete or unsupported") return EmploymentMutationCommand( tenant_record_id=tenant_record_id, + employing_organization_unit_id=UUID( + _require_string_field(payload, "employing_organization_unit_id") + ), 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(), audit_event_record_id=id_factory(), outbox_delivery_record_id=id_factory(), employment_status_code=_require_string_field(payload, "employment_status_code"), @@ -634,4 +639,4 @@ def _dispatch_mutation( mutation_port=app.mutation_port, ) created = str(result.assignment_record_id) - return {"assignment_record_id": created}, f"/v1/assignment-records/{created}" \ No newline at end of file + return {"assignment_record_id": created}, f"/v1/assignment-records/{created}" diff --git a/services/people-api/src/orgmetra_people_api/mutations.py b/services/people-api/src/orgmetra_people_api/mutations.py index 6baeac684..687dc09e2 100644 --- a/services/people-api/src/orgmetra_people_api/mutations.py +++ b/services/people-api/src/orgmetra_people_api/mutations.py @@ -22,7 +22,7 @@ from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy from orgmetra_people_api.auth import AuthenticatedPrincipal -from orgmetra_people_api.authorization import authorize_resource_fields +from orgmetra_people_api.authorization import authorize_resource_fields, organization_unit_scope_code _MAX_UUID_INT = (1 << 128) - 1 _IDEMPOTENCY_MIN = 16 @@ -123,6 +123,7 @@ 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), "employment_status_code": command.employment_status_code, "evidence_version_code": command.evidence_version_code, "person_record_id": str(command.person_record_id), @@ -166,9 +167,11 @@ class EmploymentMutationCommand: """Opaque identities and high-impact evidence needed to create one employment.""" tenant_record_id: UUID + employing_organization_unit_id: UUID person_record_id: UUID employment_record_id: UUID employment_record_version_id: UUID + employment_employing_organization_record_id: UUID audit_event_record_id: UUID outbox_delivery_record_id: UUID employment_status_code: str @@ -182,9 +185,11 @@ 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", ): @@ -376,6 +381,7 @@ 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), ) 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 4c328e02f..a05276f1a 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_hire.py +++ b/services/people-api/src/orgmetra_people_api/postgres_hire.py @@ -22,6 +22,7 @@ from orgmetra_hris_kernel.audit import AuditOutboxEvent from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.hire import ( HireAcceptanceCommand, HireAcceptanceResult, @@ -134,6 +135,17 @@ ) VALUES (%s, %s, %s, %s, %s, %s) """.strip() +_INSERT_EMPLOYMENT_EMPLOYING_ORGANIZATION_SQL = """ +INSERT INTO public.employment_employing_organization_record ( + tenant_record_id, + employment_employing_organization_record_id, + employment_record_id, + employing_organization_unit_id, + effective_from, + recorded_from +) VALUES (%s, %s, %s, %s, %s, %s) +""".strip() + _RECORD_AUDIT_OUTBOX_SQL = "SELECT public.record_audit_outbox_event(%s, %s, %s, %s, %s, %s)" _INSERT_CONVERSION_SQL = """ @@ -174,8 +186,9 @@ def _is_aware_datetime(value: object) -> bool: def _validate_authorization(command: HireAcceptanceCommand, authorization: object) -> AuthorizationDecision: - """Require an exact allow decision for this immutable selection decision.""" + """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) if not isinstance(authorization, AuthorizationDecision): raise HireDecisionIntegrityError("hire mutation requires a typed authorization decision") if ( @@ -186,6 +199,7 @@ def _validate_authorization(command: HireAcceptanceCommand, authorization: objec or authorization.operation_code != "materialize_worker" or authorization.requested_fields != _HIRE_MUTATION_FIELDS or authorization.authorized_fields != _HIRE_MUTATION_FIELDS + or authorization.required_target_scope_code != expected_target_scope ): raise HireDecisionIntegrityError("hire mutation authorization does not match the exact decision") return authorization @@ -204,7 +218,11 @@ def _hire_command_digest(command: HireAcceptanceCommand, authorization: Authoriz "candidate_worker_conversion_record_id": str(command.candidate_worker_conversion_record_id), "display_name": command.display_name, "effective_from": command.effective_from.isoformat(), + "employing_organization_unit_id": str(command.employing_organization_unit_id), "employment_record_id": str(command.employment_record_id), + "employment_employing_organization_record_id": str( + command.employment_employing_organization_record_id + ), "employment_record_version_id": str(command.employment_record_version_id), "employment_status_code": command.employment_status_code, "outbox_delivery_record_id": str(command.outbox_delivery_record_id), @@ -422,6 +440,18 @@ def accept_hire( transaction_recorded_at, ), ) + if command.employment_status_code in {"active", "leave"}: + cursor.execute( + _INSERT_EMPLOYMENT_EMPLOYING_ORGANIZATION_SQL, + ( + command.tenant_record_id, + command.employment_employing_organization_record_id, + command.employment_record_id, + command.employing_organization_unit_id, + command.effective_from, + transaction_recorded_at, + ), + ) cursor.execute( _RECORD_AUDIT_OUTBOX_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 d94832cf8..dafa4b6f4 100644 --- a/services/people-api/src/orgmetra_people_api/postgres_mutations.py +++ b/services/people-api/src/orgmetra_people_api/postgres_mutations.py @@ -28,6 +28,7 @@ ) from orgmetra_keyverse_adapter import AuthorizationDecision +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.mutations import ( AssignmentMutationCommand, AssignmentMutationResult, @@ -105,6 +106,17 @@ ) VALUES (%s, %s, %s, %s, %s, %s, %s) """.strip() +_INSERT_EMPLOYMENT_EMPLOYING_ORGANIZATION_SQL = """ +INSERT INTO public.employment_employing_organization_record ( + tenant_record_id, + employment_employing_organization_record_id, + employment_record_id, + employing_organization_unit_id, + effective_from, + recorded_from +) VALUES (%s, %s, %s, %s, %s, %s) +""".strip() + _POSITION_PARENTS_SQL = """ SELECT organization.organization_unit_id, @@ -320,8 +332,9 @@ def _require_authorization( resource_reference: str, resource_kind: str, requested_fields: frozenset[str], + required_target_scope_code: str | None = None, ) -> AuthorizationDecision: - """Require an exact allow decision for the intended mutation target.""" + """Require an exact allow decision for the intended mutation and governed target.""" if not isinstance(authorization, AuthorizationDecision): raise PeopleMutationIntegrityError("people mutation requires a typed authorization decision") if ( @@ -332,6 +345,7 @@ def _require_authorization( or authorization.operation_code != "create_record" or authorization.requested_fields != requested_fields or authorization.authorized_fields != requested_fields + or authorization.required_target_scope_code != required_target_scope_code ): raise PeopleMutationIntegrityError("people mutation authorization does not match the exact record") return authorization @@ -542,6 +556,7 @@ 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), ) with self.connection_factory() as connection: with connection.cursor() as cursor: @@ -601,6 +616,18 @@ def create_employment( recorded_at, ), ) + if command.employment_status_code in {"active", "leave"}: + cursor.execute( + _INSERT_EMPLOYMENT_EMPLOYING_ORGANIZATION_SQL, + ( + command.tenant_record_id, + command.employment_employing_organization_record_id, + command.employment_record_id, + command.employing_organization_unit_id, + command.effective_from, + recorded_at, + ), + ) _record_audit( cursor, command_tenant=command.tenant_record_id, diff --git a/services/people-api/tests/test_decision_reason_binding.py b/services/people-api/tests/test_decision_reason_binding.py index dd1b11eed..c2a4f85fb 100644 --- a/services/people-api/tests/test_decision_reason_binding.py +++ b/services/people-api/tests/test_decision_reason_binding.py @@ -13,11 +13,13 @@ TENANT = UUID("0198a412-8a00-7000-8000-000000000001") PERSON = UUID("0198a412-8a00-7000-8000-000000000002") +ORGANIZATION = UUID("0198a412-8a00-7000-8000-000000000003") GENERATED_IDS = ( UUID("0198a412-8a00-7000-8000-000000000010"), UUID("0198a412-8a00-7000-8000-000000000011"), UUID("0198a412-8a00-7000-8000-000000000012"), UUID("0198a412-8a00-7000-8000-000000000013"), + UUID("0198a412-8a00-7000-8000-000000000014"), ) _GOVERNANCE_BINDING_PATTERN = re.compile(r"^governance_evidence_v1:[0-9a-f]{64}$") @@ -34,6 +36,7 @@ def next_id() -> UUID: def _payload(*, decision_reason: str, evidence_references: list[dict[str, str]] | None = None) -> dict[str, object]: """Return one high-impact employment command payload.""" return { + "employing_organization_unit_id": str(ORGANIZATION), "person_record_id": str(PERSON), "employment_status_code": "active", "employment_concurrency_code": "exclusive", diff --git a/services/people-api/tests/test_employing_organization_authorization_binding.py b/services/people-api/tests/test_employing_organization_authorization_binding.py new file mode 100644 index 000000000..805bde4ce --- /dev/null +++ b/services/people-api/tests/test_employing_organization_authorization_binding.py @@ -0,0 +1,147 @@ +"""Regression contracts for exact employing-organization authorization evidence binding.""" + +from types import SimpleNamespace +from uuid import UUID + +import pytest + +from orgmetra_keyverse_adapter import ( + AuthorizationDecision, + PurposeBoundAccessPolicy, + PurposeBoundAccessRequest, + require_purpose_bound_access, +) +from orgmetra_people_api.authorization import organization_unit_scope_code +from orgmetra_people_api.hire import HireDecisionIntegrityError +from orgmetra_people_api.mutations import PeopleMutationIntegrityError +from orgmetra_people_api.postgres_hire import _validate_authorization as validate_hire_authorization +from orgmetra_people_api.postgres_mutations import _require_authorization as require_mutation_authorization + +TENANT = UUID("0198a412-8200-7000-8000-000000000001") +EMPLOYMENT = UUID("0198a412-8200-7000-8000-000000000030") +SELECTION_DECISION = UUID("0198a412-8200-7000-8000-000000000081") +ORGANIZATION = UUID("0198a412-8200-7000-8000-000000000050") +OTHER_ORGANIZATION = UUID("0198a412-8200-7000-8000-000000000051") +TARGET_SCOPE = organization_unit_scope_code(ORGANIZATION) +OTHER_TARGET_SCOPE = organization_unit_scope_code(OTHER_ORGANIZATION) +ACTOR = "keyverse_subject:operator-17" + + +def _allowed_decision( + *, + resource_kind: str, + resource_reference: str, + operation_code: str, + requested_fields: frozenset[str], + target_scope: str, +) -> AuthorizationDecision: + """Return a real policy-evaluator decision bound to one exact target scope.""" + policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="target-binding-v1", + resource_kind=resource_kind, + purpose_code="workforce_admin", + operation_code=operation_code, + required_scope_code="orgmetra.people.write", + permitted_fields=requested_fields, + ) + request = PurposeBoundAccessRequest( + tenant_record_id=TENANT, + actor_tenant_record_id=TENANT, + resource_tenant_record_id=TENANT, + actor_reference=ACTOR, + resource_reference=resource_reference, + purpose_code="workforce_admin", + operation_code=operation_code, + resource_kind=resource_kind, + requested_fields=requested_fields, + granted_scope_codes=frozenset({"orgmetra.people.write", target_scope}), + required_target_scope_code=target_scope, + ) + return require_purpose_bound_access(request=request, policy=policy) + + +def test_allow_decision_preserves_exact_target_scope_as_evidence() -> None: + """An allow decision must prove which governed organization target was checked.""" + decision = _allowed_decision( + resource_kind="employment_record", + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + operation_code="create_record", + requested_fields=frozenset({"employment_record"}), + target_scope=TARGET_SCOPE, + ) + + assert decision.required_target_scope_code == TARGET_SCOPE + + +def test_employment_persistence_rejects_allow_for_different_organization_scope() -> None: + """The DB adapter must not trust an allow decision bound to another organization.""" + decision = _allowed_decision( + resource_kind="employment_record", + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + operation_code="create_record", + requested_fields=frozenset({"employment_record"}), + target_scope=OTHER_TARGET_SCOPE, + ) + + with pytest.raises(PeopleMutationIntegrityError, match="authorization"): + require_mutation_authorization( + authorization=decision, + tenant_record_id=TENANT, + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + resource_kind="employment_record", + requested_fields=frozenset({"employment_record"}), + required_target_scope_code=TARGET_SCOPE, + ) + + +def test_hire_persistence_rejects_allow_for_different_organization_scope() -> None: + """Confirmed-hire persistence independently binds the allow to its employer target.""" + decision = _allowed_decision( + resource_kind="selection_decision", + resource_reference=f"selection_decision:{SELECTION_DECISION.hex}", + operation_code="materialize_worker", + requested_fields=frozenset({"candidate_worker_conversion"}), + target_scope=OTHER_TARGET_SCOPE, + ) + command = SimpleNamespace( + tenant_record_id=TENANT, + selection_decision_id=SELECTION_DECISION, + employing_organization_unit_id=ORGANIZATION, + ) + + with pytest.raises(HireDecisionIntegrityError, match="authorization"): + validate_hire_authorization(command, decision) # type: ignore[arg-type] + + +def test_matching_target_scope_is_accepted_by_both_persistence_validators() -> None: + """The added defense-in-depth check must preserve correctly bound callers.""" + employment_decision = _allowed_decision( + resource_kind="employment_record", + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + operation_code="create_record", + requested_fields=frozenset({"employment_record"}), + target_scope=TARGET_SCOPE, + ) + assert require_mutation_authorization( + authorization=employment_decision, + tenant_record_id=TENANT, + resource_reference=f"employment_record:{EMPLOYMENT.hex}", + resource_kind="employment_record", + requested_fields=frozenset({"employment_record"}), + required_target_scope_code=TARGET_SCOPE, + ) is employment_decision + + hire_decision = _allowed_decision( + resource_kind="selection_decision", + resource_reference=f"selection_decision:{SELECTION_DECISION.hex}", + operation_code="materialize_worker", + requested_fields=frozenset({"candidate_worker_conversion"}), + target_scope=TARGET_SCOPE, + ) + command = SimpleNamespace( + tenant_record_id=TENANT, + selection_decision_id=SELECTION_DECISION, + employing_organization_unit_id=ORGANIZATION, + ) + assert validate_hire_authorization(command, hire_decision) is hire_decision # type: ignore[arg-type] 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 5c0d061c3..5e77380cb 100644 --- a/services/people-api/tests/test_evidence_reference_binding_regression.py +++ b/services/people-api/tests/test_evidence_reference_binding_regression.py @@ -14,6 +14,8 @@ PERSON = UUID("0198a412-8200-7000-8000-000000000020") EMPLOYMENT = UUID("0198a412-8200-7000-8000-000000000030") EMPLOYMENT_VERSION = UUID("0198a412-8200-7000-8000-000000000031") +ORGANIZATION = UUID("0198a412-8200-7000-8000-000000000050") +EMPLOYMENT_EMPLOYER = UUID("0198a412-8200-7000-8000-000000000082") AUDIT_EVENT = UUID("0198a412-8200-7000-8000-000000000080") OUTBOX = UUID("0198a412-8200-7000-8000-000000000081") @@ -22,7 +24,7 @@ class SequentialIdFactory: """Return deterministic identifiers for one HTTP command construction.""" def __init__(self) -> None: - self._values = iter((EMPLOYMENT, EMPLOYMENT_VERSION, AUDIT_EVENT, OUTBOX)) + self._values = iter((EMPLOYMENT, EMPLOYMENT_VERSION, EMPLOYMENT_EMPLOYER, AUDIT_EVENT, OUTBOX)) def __call__(self) -> UUID: """Return the next deterministic operational UUID.""" @@ -32,6 +34,7 @@ def __call__(self) -> UUID: def employment_payload(evidence_references: list[object]) -> dict[str, object]: """Return one otherwise-valid high-impact employment request body.""" return { + "employing_organization_unit_id": str(ORGANIZATION), "person_record_id": str(PERSON), "employment_status_code": "active", "employment_concurrency_code": "exclusive", diff --git a/services/people-api/tests/test_hire_acceptance.py b/services/people-api/tests/test_hire_acceptance.py index af276ee90..d5802f874 100644 --- a/services/people-api/tests/test_hire_acceptance.py +++ b/services/people-api/tests/test_hire_acceptance.py @@ -8,6 +8,7 @@ from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.hire import ( HireAcceptanceCommand, HireAcceptancePort, @@ -22,6 +23,9 @@ PERSON_NAME = UUID("0198a412-7000-7000-8000-000000000021") EMPLOYMENT = UUID("0198a412-7000-7000-8000-000000000030") EMPLOYMENT_VERSION = UUID("0198a412-7000-7000-8000-000000000031") +ORGANIZATION = UUID("0198a412-7000-7000-8000-000000000070") +OTHER_ORGANIZATION = UUID("0198a412-7000-7000-8000-000000000072") +EMPLOYMENT_EMPLOYER = UUID("0198a412-7000-7000-8000-000000000071") CONVERSION = UUID("0198a412-7000-7000-8000-000000000040") AUDIT_EVENT = UUID("0198a412-7000-7000-8000-000000000050") OUTBOX_DELIVERY = UUID("0198a412-7000-7000-8000-000000000051") @@ -33,12 +37,14 @@ def command(**overrides: object) -> HireAcceptanceCommand: """Build one deterministic accepted-hire command for tests.""" values: dict[str, object] = { "tenant_record_id": TENANT, + "employing_organization_unit_id": ORGANIZATION, "candidate_profile_id": CANDIDATE, "selection_decision_id": DECISION, "person_record_id": PERSON, "person_name_record_id": PERSON_NAME, "employment_record_id": EMPLOYMENT, "employment_record_version_id": EMPLOYMENT_VERSION, + "employment_employing_organization_record_id": EMPLOYMENT_EMPLOYER, "candidate_worker_conversion_record_id": CONVERSION, "audit_event_record_id": AUDIT_EVENT, "outbox_delivery_record_id": OUTBOX_DELIVERY, @@ -67,7 +73,9 @@ def policy(*, purpose_code: str = "candidate_hire") -> PurposeBoundAccessPolicy: PRINCIPAL = AuthenticatedPrincipal( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-17", - granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), + granted_scope_codes=frozenset( + {"orgmetra.people.materialize_worker", organization_unit_scope_code(ORGANIZATION)} + ), ) @@ -134,6 +142,27 @@ def test_policy_denial_prevents_mutation(self) -> None: self.assertEqual(port.calls, []) + def test_missing_organization_scope_prevents_hire_materialization(self) -> None: + """Reject an unauthorized same-tenant employing organization before persistence.""" + port = RecordingHirePort() + principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse_subject:operator-17", + granted_scope_codes=frozenset( + {"orgmetra.people.materialize_worker", organization_unit_scope_code(ORGANIZATION)} + ), + ) + with self.assertRaises(AuthorizationDeniedError) as caught: + accept_confirmed_hire( + principal=principal, + command=command(employing_organization_unit_id=OTHER_ORGANIZATION), + purpose_code="candidate_hire", + policy=policy(), + mutation_port=port, + ) + self.assertEqual(caught.exception.reason_code, "required_scope_missing") + self.assertEqual(port.calls, []) + def test_command_rejects_reserved_or_malformed_identifiers(self) -> None: invalid_values = ( {"tenant_record_id": UUID(int=0)}, @@ -178,7 +207,9 @@ def test_service_rejects_mismatched_tenant_before_port_call(self) -> None: other_tenant_principal = AuthenticatedPrincipal( tenant_record_id=UUID("0198a412-7000-7000-8000-000000000099"), actor_reference="keyverse_subject:operator-99", - granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), + granted_scope_codes=frozenset( + {"orgmetra.people.materialize_worker", organization_unit_scope_code(ORGANIZATION)} + ), ) with self.assertRaises(AuthorizationDeniedError): diff --git a/services/people-api/tests/test_hire_http_route.py b/services/people-api/tests/test_hire_http_route.py index 6e8272490..1796cda91 100644 --- a/services/people-api/tests/test_hire_http_route.py +++ b/services/people-api/tests/test_hire_http_route.py @@ -8,6 +8,7 @@ from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy from orgmetra_people_api import AuthenticatedPrincipal, AuthenticationFailed +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.hire import ( HireAcceptanceCommand, HireAcceptanceResult, @@ -23,6 +24,8 @@ PERSON_NAME = UUID("0198a412-7200-7000-8000-000000000021") EMPLOYMENT = UUID("0198a412-7200-7000-8000-000000000030") EMPLOYMENT_VERSION = UUID("0198a412-7200-7000-8000-000000000031") +ORGANIZATION = UUID("0198a412-7200-7000-8000-000000000070") +EMPLOYMENT_EMPLOYER = UUID("0198a412-7200-7000-8000-000000000071") CONVERSION = UUID("0198a412-7200-7000-8000-000000000040") AUDIT_EVENT = UUID("0198a412-7200-7000-8000-000000000050") OUTBOX_DELIVERY = UUID("0198a412-7200-7000-8000-000000000051") @@ -34,12 +37,14 @@ def request_body(**overrides: object) -> bytes: """Return one canonical UTF-8 JSON hire request body.""" payload: dict[str, object] = { + "employing_organization_unit_id": str(ORGANIZATION), "candidate_profile_id": str(CANDIDATE), "selection_decision_id": str(DECISION), "person_record_id": str(PERSON), "person_name_record_id": str(PERSON_NAME), "employment_record_id": str(EMPLOYMENT), "employment_record_version_id": str(EMPLOYMENT_VERSION), + "employment_employing_organization_record_id": str(EMPLOYMENT_EMPLOYER), "candidate_worker_conversion_record_id": str(CONVERSION), "audit_event_record_id": str(AUDIT_EVENT), "outbox_delivery_record_id": str(OUTBOX_DELIVERY), @@ -100,7 +105,9 @@ def setUp(self) -> None: self.principal = AuthenticatedPrincipal( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-17", - granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), + granted_scope_codes=frozenset( + {"orgmetra.people.materialize_worker", organization_unit_scope_code(ORGANIZATION)} + ), ) self.policy = PurposeBoundAccessPolicy( tenant_record_id=TENANT, diff --git a/services/people-api/tests/test_hire_http_scalar_contract.py b/services/people-api/tests/test_hire_http_scalar_contract.py index 5ba2f987d..db744f3b5 100644 --- a/services/people-api/tests/test_hire_http_scalar_contract.py +++ b/services/people-api/tests/test_hire_http_scalar_contract.py @@ -20,6 +20,8 @@ def _valid_payload() -> dict[str, object]: "person_name_record_id": "0198a412-7200-7000-8000-000000000021", "employment_record_id": "0198a412-7200-7000-8000-000000000030", "employment_record_version_id": "0198a412-7200-7000-8000-000000000031", + "employing_organization_unit_id": "0198a412-7200-7000-8000-000000000070", + "employment_employing_organization_record_id": "0198a412-7200-7000-8000-000000000071", "candidate_worker_conversion_record_id": "0198a412-7200-7000-8000-000000000040", "audit_event_record_id": "0198a412-7200-7000-8000-000000000050", "outbox_delivery_record_id": "0198a412-7200-7000-8000-000000000051", 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 8c1f2bb7c..c185e70d4 100644 --- a/services/people-api/tests/test_mutation_http_authentication_order.py +++ b/services/people-api/tests/test_mutation_http_authentication_order.py @@ -8,6 +8,7 @@ from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy from orgmetra_people_api import AuthenticatedPrincipal, AuthenticationFailed +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.mutation_http import PeopleMutationAsgiApp from orgmetra_people_api.mutations import ( AssignmentMutationCommand, @@ -20,11 +21,13 @@ TENANT = UUID("0198a412-8100-7000-8000-000000000001") PERSON = UUID("0198a412-8100-7000-8000-000000000020") +ORGANIZATION = UUID("0198a412-8100-7000-8000-000000000022") MUTATION_IDS = [ UUID("0198a412-8100-7000-8000-000000000030"), UUID("0198a412-8100-7000-8000-000000000031"), UUID("0198a412-8100-7000-8000-000000000032"), UUID("0198a412-8100-7000-8000-000000000033"), + UUID("0198a412-8100-7000-8000-000000000034"), ] @@ -119,7 +122,7 @@ def _principal() -> AuthenticatedPrincipal: return AuthenticatedPrincipal( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-17", - granted_scope_codes=frozenset({"orgmetra.people.write"}), + granted_scope_codes=frozenset({"orgmetra.people.write", organization_unit_scope_code(ORGANIZATION)}), ) @@ -203,6 +206,7 @@ async def test_unexpected_backend_error_logs_only_safe_correlation_fields(self) ) body = json.dumps( { + "employing_organization_unit_id": "0198a412-8100-7000-8000-000000000022", "person_record_id": str(PERSON), "employment_status_code": "active", "employment_concurrency_code": "exclusive", @@ -236,7 +240,7 @@ async def send(message: dict[str, object]) -> None: self.assertEqual(record.tenant_record_id, str(TENANT)) self.assertEqual( record.correlation_reference, - f"audit_event_record:{MUTATION_IDS[2].hex}", + f"audit_event_record:{MUTATION_IDS[3].hex}", ) self.assertEqual(record.exception_type, "RuntimeError") rendered = " ".join(captured.output) diff --git a/services/people-api/tests/test_mutation_http_route.py b/services/people-api/tests/test_mutation_http_route.py index 941580e40..df8dc39ec 100644 --- a/services/people-api/tests/test_mutation_http_route.py +++ b/services/people-api/tests/test_mutation_http_route.py @@ -10,6 +10,7 @@ from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy from orgmetra_people_api import AuthenticatedPrincipal, AuthenticationFailed +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.mutation_http import PeopleMutationAsgiApp from orgmetra_people_api.mutations import ( AssignmentMutationCommand, @@ -32,6 +33,7 @@ IDS = [ EMPLOYMENT, UUID("0198a412-8100-7000-8000-000000000031"), + UUID("0198a412-8100-7000-8000-000000000086"), UUID("0198a412-8100-7000-8000-000000000080"), UUID("0198a412-8100-7000-8000-000000000081"), POSITION, @@ -103,6 +105,7 @@ def create_assignment(self, *, command: AssignmentMutationCommand, authorization def employment_body(**overrides: object) -> bytes: """Return one canonical employment command body.""" payload: dict[str, object] = { + "employing_organization_unit_id": str(ORGANIZATION), "person_record_id": str(PERSON), "employment_status_code": "active", "employment_concurrency_code": "exclusive", @@ -157,7 +160,13 @@ def setUp(self) -> None: self.principal = AuthenticatedPrincipal( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-17", - granted_scope_codes=frozenset({"orgmetra.people.write", "orgmetra.job_architecture.write"}), + granted_scope_codes=frozenset( + { + "orgmetra.people.write", + "orgmetra.job_architecture.write", + organization_unit_scope_code(ORGANIZATION), + } + ), ) self.employment_policy = PurposeBoundAccessPolicy( tenant_record_id=TENANT, @@ -494,4 +503,4 @@ async def send(message: dict[str, object]) -> None: if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() 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 7a42df43d..a8d37fa94 100644 --- a/services/people-api/tests/test_mutation_http_schema_types.py +++ b/services/people-api/tests/test_mutation_http_schema_types.py @@ -10,11 +10,13 @@ TENANT = UUID("0198a412-8100-7000-8000-000000000001") PERSON = UUID("0198a412-8100-7000-8000-000000000020") +ORGANIZATION = UUID("0198a412-8100-7000-8000-000000000022") _GENERATED_IDS = tuple( UUID(value) for value in ( "0198a412-8100-7000-8000-000000000030", "0198a412-8100-7000-8000-000000000031", + "0198a412-8100-7000-8000-000000000032", "0198a412-8100-7000-8000-000000000080", "0198a412-8100-7000-8000-000000000081", ) @@ -30,6 +32,7 @@ def id_factory() -> Callable[[], UUID]: def employment_payload(**overrides: object) -> dict[str, object]: """Return one canonical employment request payload with optional field overrides.""" payload: dict[str, object] = { + "employing_organization_unit_id": str(ORGANIZATION), "person_record_id": str(PERSON), "employment_status_code": "active", "employment_concurrency_code": "exclusive", diff --git a/services/people-api/tests/test_people_mutations.py b/services/people-api/tests/test_people_mutations.py index b629355e9..45fc531e4 100644 --- a/services/people-api/tests/test_people_mutations.py +++ b/services/people-api/tests/test_people_mutations.py @@ -9,6 +9,7 @@ from orgmetra_keyverse_adapter import AuthorizationDecision, AuthorizationDeniedError, PurposeBoundAccessPolicy from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.mutations import ( AssignmentMutationCommand, AssignmentMutationResult, @@ -33,6 +34,7 @@ POSITION = UUID("0198a412-8000-7000-8000-000000000040") POSITION_VERSION = UUID("0198a412-8000-7000-8000-000000000041") ORGANIZATION = UUID("0198a412-8000-7000-8000-000000000050") +OTHER_ORGANIZATION = UUID("0198a412-8000-7000-8000-000000000051") JOB = UUID("0198a412-8000-7000-8000-000000000060") ASSIGNMENT = UUID("0198a412-8000-7000-8000-000000000070") AUDIT_EVENT = UUID("0198a412-8000-7000-8000-000000000080") @@ -47,9 +49,11 @@ def employment_command(**overrides: object) -> EmploymentMutationCommand: """Build one deterministic employment command.""" values: dict[str, object] = { "tenant_record_id": TENANT, + "employing_organization_unit_id": ORGANIZATION, "person_record_id": PERSON, "employment_record_id": EMPLOYMENT, "employment_record_version_id": EMPLOYMENT_VERSION, + "employment_employing_organization_record_id": UUID("0198a412-8000-7000-8000-000000000051"), "audit_event_record_id": AUDIT_EVENT, "outbox_delivery_record_id": OUTBOX, "employment_status_code": "active", @@ -145,7 +149,13 @@ def assignment_policy() -> PurposeBoundAccessPolicy: PRINCIPAL = AuthenticatedPrincipal( tenant_record_id=TENANT, actor_reference="keyverse_subject:operator-17", - granted_scope_codes=frozenset({"orgmetra.people.write", "orgmetra.job_architecture.write"}), + granted_scope_codes=frozenset( + { + "orgmetra.people.write", + "orgmetra.job_architecture.write", + organization_unit_scope_code(ORGANIZATION), + } + ), ) @@ -232,6 +242,27 @@ def test_policy_denial_prevents_mutation(self) -> None: ) self.assertEqual(port.employment_calls, []) + def test_missing_organization_scope_prevents_employment_mutation(self) -> None: + """Reject an unauthorized same-tenant employing organization before persistence.""" + port = RecordingMutationPort() + with self.assertRaises(AuthorizationDeniedError) as caught: + create_employment_record( + principal=PRINCIPAL, + command=employment_command(employing_organization_unit_id=OTHER_ORGANIZATION), + purpose_code="workforce_admin", + policy=employment_policy(), + mutation_port=port, + ) + self.assertEqual(caught.exception.reason_code, "required_scope_missing") + self.assertEqual(port.employment_calls, []) + + def test_organization_scope_code_rejects_reserved_or_malformed_ids(self) -> None: + """Reject invalid organization identities before constructing a target scope.""" + with self.assertRaises(ValueError): + organization_unit_scope_code(UUID(int=0)) + with self.assertRaises(ValueError): + organization_unit_scope_code("not-a-uuid") # type: ignore[arg-type] + def test_commands_reject_reserved_or_malformed_values(self) -> None: cases = ( lambda: employment_command(tenant_record_id=UUID(int=0)), diff --git a/services/people-api/tests/test_postgres_hire_acceptance.py b/services/people-api/tests/test_postgres_hire_acceptance.py index ccd6009ff..8ac9794a8 100644 --- a/services/people-api/tests/test_postgres_hire_acceptance.py +++ b/services/people-api/tests/test_postgres_hire_acceptance.py @@ -10,6 +10,7 @@ from orgmetra_keyverse_adapter import AuthorizationDecision, PurposeBoundAccessPolicy from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.hire import ( HireAcceptanceCommand, HireAcceptanceResult, @@ -29,6 +30,8 @@ PERSON_NAME = UUID("0198a412-7100-7000-8000-000000000021") EMPLOYMENT = UUID("0198a412-7100-7000-8000-000000000030") EMPLOYMENT_VERSION = UUID("0198a412-7100-7000-8000-000000000031") +ORGANIZATION = UUID("0198a412-7100-7000-8000-000000000070") +EMPLOYMENT_EMPLOYER = UUID("0198a412-7100-7000-8000-000000000071") CONVERSION = UUID("0198a412-7100-7000-8000-000000000040") AUDIT_EVENT = UUID("0198a412-7100-7000-8000-000000000050") OUTBOX_DELIVERY = UUID("0198a412-7100-7000-8000-000000000051") @@ -62,12 +65,14 @@ def command(**overrides: object) -> HireAcceptanceCommand: """Build one deterministic hire command.""" values: dict[str, object] = { "tenant_record_id": TENANT, + "employing_organization_unit_id": ORGANIZATION, "candidate_profile_id": CANDIDATE, "selection_decision_id": DECISION, "person_record_id": PERSON, "person_name_record_id": PERSON_NAME, "employment_record_id": EMPLOYMENT, "employment_record_version_id": EMPLOYMENT_VERSION, + "employment_employing_organization_record_id": EMPLOYMENT_EMPLOYER, "candidate_worker_conversion_record_id": CONVERSION, "audit_event_record_id": AUDIT_EVENT, "outbox_delivery_record_id": OUTBOX_DELIVERY, @@ -132,7 +137,9 @@ def allowed_authorization() -> AuthorizationDecision: PRINCIPAL = AuthenticatedPrincipal( tenant_record_id=TENANT, actor_reference=ACTOR, - granted_scope_codes=frozenset({"orgmetra.people.materialize_worker"}), + granted_scope_codes=frozenset( + {"orgmetra.people.materialize_worker", organization_unit_scope_code(ORGANIZATION)} + ), ) @@ -250,12 +257,20 @@ def test_materializes_confirmed_hire_and_pii_minimized_audit_atomically(self) -> "public.person_name_record", "public.employment_record", "public.employment_record_version", + "public.employment_employing_organization_record", ) - for execution, table_name in zip(cursor.executions[5:9], expected_tables, strict=True): + for execution, table_name in zip(cursor.executions[5:10], expected_tables, strict=True): self.assertIn(table_name, execution[0]) self.assertEqual(cursor.executions[6][1][3], "Ada Lovelace") - audit_sql, audit_parameters = cursor.executions[9] + relation_sql, relation_parameters = cursor.executions[9] + self.assertEqual( + relation_parameters, + (TENANT, EMPLOYMENT_EMPLOYER, EMPLOYMENT, ORGANIZATION, DECIDED_AT.date(), TRANSACTION_AT), + ) + self.assertIn("employing_organization_unit_id", relation_sql) + + audit_sql, audit_parameters = cursor.executions[10] self.assertEqual(audit_sql, "SELECT public.record_audit_outbox_event(%s, %s, %s, %s, %s, %s)") self.assertIsNotNone(audit_parameters) assert audit_parameters is not None @@ -277,13 +292,13 @@ def test_materializes_confirmed_hire_and_pii_minimized_audit_atomically(self) -> self.assertEqual(envelope["time"], "2026-08-18T00:01:00Z") self.assertNotIn("Ada Lovelace", envelope_text) - conversion_sql, conversion_parameters = cursor.executions[10] + conversion_sql, conversion_parameters = cursor.executions[11] self.assertIn("public.candidate_worker_conversion_record", conversion_sql) self.assertEqual( conversion_parameters, (TENANT, CONVERSION, CANDIDATE, PERSON, EMPLOYMENT, DECISION, AUDIT_EVENT, DECIDED_AT.date(), TRANSACTION_AT), ) - idempotency_sql, idempotency_parameters = cursor.executions[11] + idempotency_sql, idempotency_parameters = cursor.executions[12] self.assertIn("public.people_mutation_idempotency_record", idempotency_sql) self.assertIsNotNone(idempotency_parameters) assert idempotency_parameters is not None @@ -292,6 +307,23 @@ def test_materializes_confirmed_hire_and_pii_minimized_audit_atomically(self) -> self.assertRegex(str(idempotency_parameters[4]), r"^[0-9a-f]{64}$") self.assertEqual(idempotency_parameters[5], CONVERSION) + def test_terminated_hire_does_not_write_employing_organization(self) -> None: + port, connection, cursor = self._port([decision_row()]) + + result = accept_confirmed_hire( + principal=PRINCIPAL, + command=command(employment_status_code="terminated"), + purpose_code=PURPOSE, + policy=policy(), + mutation_port=port, + ) + + self.assertEqual(result.employment_record_id, EMPLOYMENT) + self.assertFalse( + any("public.employment_employing_organization_record" in sql for sql, _ in cursor.executions) + ) + self.assertIsNone(connection.exit_exception) + def test_exact_committed_hire_replay_returns_without_duplicate_business_writes(self) -> None: request = command() digest = _hire_command_digest(request, allowed_authorization()) diff --git a/services/people-api/tests/test_postgres_people_mutations.py b/services/people-api/tests/test_postgres_people_mutations.py index 0175fc6d4..7133bcbc1 100644 --- a/services/people-api/tests/test_postgres_people_mutations.py +++ b/services/people-api/tests/test_postgres_people_mutations.py @@ -11,6 +11,7 @@ from orgmetra_keyverse_adapter import AuthorizationDecision from orgmetra_people_api.auth import AuthenticatedPrincipal +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.mutations import ( PeopleMutationIntegrityError, PeopleMutationNotFound, @@ -184,7 +185,13 @@ def covering_position_row() -> tuple[object, ...]: PRINCIPAL = AuthenticatedPrincipal( tenant_record_id=TENANT, actor_reference=ACTOR, - granted_scope_codes=frozenset({"orgmetra.people.write", "orgmetra.job_architecture.write"}), + granted_scope_codes=frozenset( + { + "orgmetra.people.write", + "orgmetra.job_architecture.write", + organization_unit_scope_code(ORGANIZATION), + } + ), ) @@ -209,9 +216,10 @@ def _port( def test_employment_requires_conversion_and_records_audit_atomically(self) -> None: port, cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[]]) + request = employment_command() result = create_employment_record( principal=PRINCIPAL, - command=employment_command(), + command=request, purpose_code="workforce_admin", policy=employment_policy(), mutation_port=port, @@ -225,6 +233,16 @@ def test_employment_requires_conversion_and_records_audit_atomically(self) -> No self.assertIn("conversion.recorded_to IS NULL", conversion_sql) self.assertIn("public.employment_record", sql_text) self.assertIn("employment_concurrency_code", sql_text) + relation_sql, relation_parameters = next( + execution + for execution in cursor.executions + if "public.employment_employing_organization_record" in execution[0] + ) + self.assertEqual( + relation_parameters, + (TENANT, request.employment_employing_organization_record_id, EMPLOYMENT, ORGANIZATION, request.effective_from, RECORDED_AT), + ) + self.assertIn("employing_organization_unit_id", relation_sql) self.assertIn("public.record_audit_outbox_event", sql_text) self.assertIn("public.people_mutation_idempotency_record", sql_text) self.assertNotIn("candidate_worker_link", sql_text) @@ -238,6 +256,23 @@ def test_employment_requires_conversion_and_records_audit_atomically(self) -> No self.assertEqual(envelope["orgmetraconfirmation"], "human_confirmation:review-88") self.assertEqual(audit_parameters[4], sha256(audit_parameters[3].encode("utf-8")).hexdigest()) + def test_terminated_employment_does_not_write_employing_organization(self) -> None: + port, cursor = self._port([[(CONVERSION, RECORDED_AT)]], [[]]) + request = employment_command(employment_status_code="terminated") + + result = create_employment_record( + principal=PRINCIPAL, + command=request, + purpose_code="workforce_admin", + policy=employment_policy(), + mutation_port=port, + ) + + self.assertEqual(result.employment_record_id, EMPLOYMENT) + self.assertFalse( + any("public.employment_employing_organization_record" in sql for sql, _ in cursor.executions) + ) + def test_position_requires_parents_and_records_audit(self) -> None: port, cursor = self._port([[(ORGANIZATION, JOB, RECORDED_AT)]]) result = create_position_record( diff --git a/services/people-api/tests/test_review_regressions.py b/services/people-api/tests/test_review_regressions.py index 82da96660..4108cc6c3 100644 --- a/services/people-api/tests/test_review_regressions.py +++ b/services/people-api/tests/test_review_regressions.py @@ -75,12 +75,14 @@ def test_confirmed_hire_command_carries_validated_idempotency_key(self) -> None: """Confirmed-hire retries need the same durable key boundary as other writes.""" command = HireAcceptanceCommand( tenant_record_id=UUID("0198a412-8000-7000-8000-000000000001"), + employing_organization_unit_id=UUID("0198a412-8000-7000-8000-000000000050"), candidate_profile_id=UUID("0198a412-8000-7000-8000-000000000002"), selection_decision_id=UUID("0198a412-8000-7000-8000-000000000003"), person_record_id=UUID("0198a412-8000-7000-8000-000000000004"), person_name_record_id=UUID("0198a412-8000-7000-8000-000000000005"), employment_record_id=UUID("0198a412-8000-7000-8000-000000000006"), employment_record_version_id=UUID("0198a412-8000-7000-8000-000000000007"), + employment_employing_organization_record_id=UUID("0198a412-8000-7000-8000-00000000000b"), candidate_worker_conversion_record_id=UUID("0198a412-8000-7000-8000-000000000008"), audit_event_record_id=UUID("0198a412-8000-7000-8000-000000000009"), outbox_delivery_record_id=UUID("0198a412-8000-7000-8000-00000000000a"), @@ -93,12 +95,14 @@ def test_confirmed_hire_command_carries_validated_idempotency_key(self) -> None: with self.assertRaises(ValueError): HireAcceptanceCommand( tenant_record_id=command.tenant_record_id, + employing_organization_unit_id=command.employing_organization_unit_id, candidate_profile_id=command.candidate_profile_id, selection_decision_id=command.selection_decision_id, person_record_id=command.person_record_id, person_name_record_id=command.person_name_record_id, employment_record_id=command.employment_record_id, employment_record_version_id=command.employment_record_version_id, + employment_employing_organization_record_id=command.employment_employing_organization_record_id, candidate_worker_conversion_record_id=command.candidate_worker_conversion_record_id, audit_event_record_id=command.audit_event_record_id, outbox_delivery_record_id=command.outbox_delivery_record_id, diff --git a/services/people-api/tests/test_support_reference_correlation.py b/services/people-api/tests/test_support_reference_correlation.py index c60f774be..0b33126c0 100644 --- a/services/people-api/tests/test_support_reference_correlation.py +++ b/services/people-api/tests/test_support_reference_correlation.py @@ -8,6 +8,7 @@ from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy from orgmetra_people_api import AuthenticatedPrincipal +from orgmetra_people_api.authorization import organization_unit_scope_code from orgmetra_people_api.hire import HireAcceptanceCommand from orgmetra_people_api.hire_http import HireAcceptanceAsgiApp from orgmetra_people_api.mutation_http import PeopleMutationAsgiApp @@ -22,6 +23,7 @@ CANDIDATE = UUID("0198a412-9900-7000-8000-000000000010") DECISION = UUID("0198a412-9900-7000-8000-000000000011") EMPLOYMENT = UUID("0198a412-9900-7000-8000-000000000030") +ORGANIZATION = UUID("0198a412-9900-7000-8000-000000000040") AUDIT_EVENT = UUID("0198a412-9900-7000-8000-000000000050") @@ -78,6 +80,7 @@ def __init__(self) -> None: ( EMPLOYMENT, UUID("0198a412-9900-7000-8000-000000000031"), + UUID("0198a412-9900-7000-8000-000000000032"), AUDIT_EVENT, UUID("0198a412-9900-7000-8000-000000000051"), ) @@ -130,6 +133,7 @@ def setUp(self) -> None: actor_reference="keyverse_subject:operator-99", granted_scope_codes=frozenset( {"orgmetra.people.materialize_worker", "orgmetra.people.write", "orgmetra.job_architecture.write"} + | {organization_unit_scope_code(ORGANIZATION)} ), ) @@ -188,12 +192,14 @@ async def test_confirmed_hire_500_support_reference_matches_error_log(self) -> N mutation_port=FailingHirePort(), ) payload = { + "employing_organization_unit_id": str(ORGANIZATION), "candidate_profile_id": str(CANDIDATE), "selection_decision_id": str(DECISION), "person_record_id": str(PERSON), "person_name_record_id": "0198a412-9900-7000-8000-000000000021", "employment_record_id": str(EMPLOYMENT), "employment_record_version_id": "0198a412-9900-7000-8000-000000000031", + "employment_employing_organization_record_id": "0198a412-9900-7000-8000-000000000032", "candidate_worker_conversion_record_id": "0198a412-9900-7000-8000-000000000040", "audit_event_record_id": str(AUDIT_EVENT), "outbox_delivery_record_id": "0198a412-9900-7000-8000-000000000051", @@ -232,6 +238,7 @@ async def test_people_mutation_500_support_reference_matches_error_log(self) -> id_factory=SequentialIdFactory(), ) payload = { + "employing_organization_unit_id": str(ORGANIZATION), "person_record_id": str(PERSON), "employment_status_code": "active", "employment_concurrency_code": "exclusive", 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 0db1f7363..492495cc2 100644 --- a/services/people-api/tests/test_support_reference_response_privacy.py +++ b/services/people-api/tests/test_support_reference_response_privacy.py @@ -30,12 +30,14 @@ async def test_confirmed_hire_persistence_secret_is_absent_from_response(self) - mutation_port=_SUPPORT.FailingHirePort(), ) payload = { + "employing_organization_unit_id": str(_SUPPORT.ORGANIZATION), "candidate_profile_id": str(_SUPPORT.CANDIDATE), "selection_decision_id": str(_SUPPORT.DECISION), "person_record_id": str(_SUPPORT.PERSON), "person_name_record_id": "0198a412-9900-7000-8000-000000000021", "employment_record_id": str(_SUPPORT.EMPLOYMENT), "employment_record_version_id": "0198a412-9900-7000-8000-000000000031", + "employment_employing_organization_record_id": "0198a412-9900-7000-8000-000000000032", "candidate_worker_conversion_record_id": "0198a412-9900-7000-8000-000000000040", "audit_event_record_id": str(_SUPPORT.AUDIT_EVENT), "outbox_delivery_record_id": "0198a412-9900-7000-8000-000000000051", @@ -74,6 +76,7 @@ async def test_people_mutation_persistence_secret_is_absent_from_response(self) id_factory=_SUPPORT.SequentialIdFactory(), ) payload = { + "employing_organization_unit_id": str(_SUPPORT.ORGANIZATION), "person_record_id": str(_SUPPORT.PERSON), "employment_status_code": "active", "employment_concurrency_code": "exclusive", diff --git a/tests/foundation-contract.test.mjs b/tests/foundation-contract.test.mjs index 72b18466f..8c8097fa7 100644 --- a/tests/foundation-contract.test.mjs +++ b/tests/foundation-contract.test.mjs @@ -53,6 +53,11 @@ function writeMigrationBackedTables(root) { 'CREATE TABLE job_analysis_write_command (tenant_record_id uuid NOT NULL);' ].join('\n') + '\n' ); + write( + root, + 'database/migrations/0040_employment_employing_organization.sql', + 'CREATE TABLE employment_employing_organization_record (tenant_record_id uuid NOT NULL);\n' + ); } function makeMinimalValidFoundation(root) { @@ -128,6 +133,8 @@ test('required constants are frozen and use accepted values', () => { assert.ok(REQUIRED_FILES.length > 20); assert.ok(DATABASE_OBJECT_NAMES.every(isValidDatabaseObjectName)); assert.ok(DATABASE_OBJECT_NAMES.includes('people_mutation_idempotency_record')); + assert.ok(DATABASE_OBJECT_NAMES.includes('employment_employing_organization_record')); + assert.ok(MIGRATION_BACKED_DATABASE_OBJECT_NAMES.includes('employment_employing_organization_record')); assert.ok(MATURITY_VALUES.has('accepted_architecture')); }); @@ -293,6 +300,21 @@ test('ADR validation is empty when the index is absent', () => { } }); +test('ADR index validates the active-PR status used by proposed ADRs', () => { + const root = temporaryDirectory(); + try { + write( + root, + 'docs/adr/README.md', + '# Index\n| ADR | Title | Status |\n|---|---|---|\n| [0141](0141.md) | Employer | proposed_on_active_pr |\n' + ); + write(root, 'docs/adr/0141.md', '# ADR\n\nStatus: proposed_on_active_pr\n'); + assert.deepEqual(validateAdrIndex(root), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('foundation validator reports a missing repository root', () => { const root = join(tmpdir(), `orgmetra-absent-${Date.now()}`); assert.match(validateFoundation(root)[0], /Repository root does not exist/); diff --git a/tests/test_employment_employing_organization_postgres.sh b/tests/test_employment_employing_organization_postgres.sh new file mode 100644 index 000000000..7510e3712 --- /dev/null +++ b/tests/test_employment_employing_organization_postgres.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0001_foundation_schema.sql +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0002_sealed_evidence_digest.sql +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0040_employment_employing_organization.sql + +index_definition="$(psql "${DATABASE_URL}" -Atqc "SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'employment_employing_organization_record' AND indexname = 'employment_employing_organization_unit_lookup_index';")" +[[ -n "${index_definition}" ]] || { + echo "missing employment_employing_organization_unit_lookup_index" >&2 + exit 1 +} +[[ "${index_definition}" == *"(tenant_record_id, employing_organization_unit_id)"* ]] || { + echo "employment_employing_organization_unit_lookup_index does not cover tenant_record_id and employing_organization_unit_id: ${index_definition}" >&2 + exit 1 +} + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +BEGIN; +INSERT INTO tenant_record (tenant_record_id, tenant_reference) VALUES + ('10000000-0000-7000-8000-000000000001','tenant_alpha'), + ('20000000-0000-7000-8000-000000000001','tenant_beta'); +INSERT INTO person_record (tenant_record_id, person_record_id) VALUES + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000010'), + ('20000000-0000-7000-8000-000000000001','20000000-0000-7000-8000-000000000010'); +INSERT INTO employment_record (tenant_record_id, employment_record_id, person_record_id) VALUES + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000020','10000000-0000-7000-8000-000000000010'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000022','10000000-0000-7000-8000-000000000010'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000026','10000000-0000-7000-8000-000000000010'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000028','10000000-0000-7000-8000-000000000010'), + ('20000000-0000-7000-8000-000000000001','20000000-0000-7000-8000-000000000020','20000000-0000-7000-8000-000000000010'); +INSERT INTO employment_record_version ( + tenant_record_id, employment_record_version_id, employment_record_id, + employment_status_code, employment_concurrency_code, effective_from, recorded_from +) VALUES + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000021','10000000-0000-7000-8000-000000000020','active','exclusive',DATE '2026-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000023','10000000-0000-7000-8000-000000000022','active','concurrent',DATE '2026-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('20000000-0000-7000-8000-000000000001','20000000-0000-7000-8000-000000000021','20000000-0000-7000-8000-000000000020','active','exclusive',DATE '2026-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'); +INSERT INTO organization_unit (tenant_record_id, organization_unit_id) VALUES + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000030'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000031'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000032'), + ('20000000-0000-7000-8000-000000000001','20000000-0000-7000-8000-000000000030'); +INSERT INTO organization_unit_version ( + tenant_record_id, organization_unit_version_id, organization_unit_id, + unit_name, organization_type_code, effective_from, recorded_from +) VALUES + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000040','10000000-0000-7000-8000-000000000030','Alpha Legal Employer','legal_entity',DATE '2025-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000041','10000000-0000-7000-8000-000000000031','Alpha Second Employer','legal_entity',DATE '2025-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000042','10000000-0000-7000-8000-000000000032','People Department','department',DATE '2025-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('20000000-0000-7000-8000-000000000001','20000000-0000-7000-8000-000000000040','20000000-0000-7000-8000-000000000030','Beta Legal Employer','legal_entity',DATE '2025-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'); +INSERT INTO employment_employing_organization_record ( + tenant_record_id, employment_employing_organization_record_id, + employment_record_id, employing_organization_unit_id, effective_from, recorded_from +) VALUES + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000050', + '10000000-0000-7000-8000-000000000020','10000000-0000-7000-8000-000000000030', + DATE '2026-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000051', + '10000000-0000-7000-8000-000000000022','10000000-0000-7000-8000-000000000031', + DATE '2026-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'), + ('20000000-0000-7000-8000-000000000001','20000000-0000-7000-8000-000000000050', + '20000000-0000-7000-8000-000000000020','20000000-0000-7000-8000-000000000030', + DATE '2026-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00'); +COMMIT; +SQL + +count="$(psql "${DATABASE_URL}" -Atqc "SELECT count(*) FROM employment_employing_organization_record WHERE tenant_record_id='10000000-0000-7000-8000-000000000001'::uuid AND employment_record_id='10000000-0000-7000-8000-000000000020'::uuid AND daterange(effective_from,effective_to,'[)') @> DATE '2026-08-28' AND tstzrange(recorded_from,recorded_to,'[)') @> TIMESTAMPTZ '2026-08-28 00:00:00+00';")" +[[ "$count" == 1 ]] || { echo "expected one employer at one business/system coordinate, got $count" >&2; exit 1; } + +expect_failure() { + local expected="$1"; shift + set +e + local output + output="$({ "$@"; } 2>&1)" + local status=$? + set -e + [[ $status -ne 0 && "$output" == *"$expected"* ]] || { echo "expected failure '$expected', got: $output" >&2; exit 1; } +} + +expect_failure employment_employing_organization_bitemporal_exclusion psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "INSERT INTO employment_employing_organization_record (tenant_record_id,employment_employing_organization_record_id,employment_record_id,employing_organization_unit_id,effective_from,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000058','10000000-0000-7000-8000-000000000020','10000000-0000-7000-8000-000000000031',DATE '2026-06-01',TIMESTAMPTZ '2026-01-04 00:00:00+00');" +expect_failure "employing organization must be a legal_entity" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "INSERT INTO employment_employing_organization_record (tenant_record_id,employment_employing_organization_record_id,employment_record_id,employing_organization_unit_id,effective_from,effective_to,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000059','10000000-0000-7000-8000-000000000020','10000000-0000-7000-8000-000000000032',DATE '2026-01-01',DATE '2026-06-01',TIMESTAMPTZ '2026-01-03 00:00:00+00');" +expect_failure "employing organization interval must be covered by active or leave Employment truth" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "INSERT INTO employment_employing_organization_record (tenant_record_id,employment_employing_organization_record_id,employment_record_id,employing_organization_unit_id,effective_from,effective_to,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000055','10000000-0000-7000-8000-000000000020','10000000-0000-7000-8000-000000000030',DATE '2025-01-01',DATE '2025-06-01',TIMESTAMPTZ '2026-01-03 00:00:00+00');" +# Separate Employment anchors prevent the cardinality exclusion from masking the tenant FK boundary. +expect_failure employment_employing_organization_unit_tenant_fk psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "INSERT INTO employment_employing_organization_record (tenant_record_id,employment_employing_organization_record_id,employment_record_id,employing_organization_unit_id,effective_from,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000053','10000000-0000-7000-8000-000000000026','20000000-0000-7000-8000-000000000030',DATE '2026-01-01',TIMESTAMPTZ '2026-01-03 00:00:00+00');" +expect_failure "exactly one legal employer" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "BEGIN; INSERT INTO employment_record_version (tenant_record_id,employment_record_version_id,employment_record_id,employment_status_code,effective_from,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000027','10000000-0000-7000-8000-000000000026','active',DATE '2026-01-01',TIMESTAMPTZ '2026-01-03 00:00:00+00'); COMMIT;" +expect_failure "exactly one legal employer" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "BEGIN; INSERT INTO employment_record_version (tenant_record_id,employment_record_version_id,employment_record_id,employment_status_code,effective_from,effective_to,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000029','10000000-0000-7000-8000-000000000028','active',DATE '2026-01-01',DATE '2026-12-31',TIMESTAMPTZ '2026-01-03 00:00:00+00'); INSERT INTO employment_employing_organization_record (tenant_record_id,employment_employing_organization_record_id,employment_record_id,employing_organization_unit_id,effective_from,effective_to,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000060','10000000-0000-7000-8000-000000000028','10000000-0000-7000-8000-000000000030',DATE '2026-01-01',DATE '2026-06-01',TIMESTAMPTZ '2026-01-03 00:00:00+00'), ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000061','10000000-0000-7000-8000-000000000028','10000000-0000-7000-8000-000000000030',DATE '2026-07-01',DATE '2026-12-31',TIMESTAMPTZ '2026-01-03 00:00:00+00'); COMMIT;" +expect_failure "bitemporal correction may only close an open recorded interval" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "UPDATE employment_employing_organization_record SET employing_organization_unit_id='10000000-0000-7000-8000-000000000031' WHERE employment_employing_organization_record_id='10000000-0000-7000-8000-000000000050';" +expect_failure "employment employing-organization history cannot be truncated" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c "TRUNCATE employment_employing_organization_record;" + +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' +BEGIN; +UPDATE employment_employing_organization_record SET recorded_to=TIMESTAMPTZ '2026-09-01 00:00:00+00' WHERE employment_employing_organization_record_id='10000000-0000-7000-8000-000000000050'; +INSERT INTO employment_employing_organization_record (tenant_record_id,employment_employing_organization_record_id,employment_record_id,employing_organization_unit_id,effective_from,recorded_from) VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000054','10000000-0000-7000-8000-000000000020','10000000-0000-7000-8000-000000000031',DATE '2026-01-01',TIMESTAMPTZ '2026-09-01 00:00:00+00'); +COMMIT; +CREATE ROLE orgmetra_employer_reader NOSUPERUSER NOBYPASSRLS; +GRANT SELECT ON employment_employing_organization_record TO orgmetra_employer_reader; +SET ROLE orgmetra_employer_reader; +SELECT set_config('orgmetra.tenant_record_id','10000000-0000-7000-8000-000000000001',false); +DO $$ BEGIN + IF (SELECT count(*) FROM employment_employing_organization_record WHERE employment_record_id='10000000-0000-7000-8000-000000000020'::uuid AND tstzrange(recorded_from, recorded_to, '[)') @> TIMESTAMPTZ '2026-09-01 00:00:00+00') <> 1 THEN + RAISE EXCEPTION 'tenant alpha should see exactly one employer fact at the selected system-time coordinate'; + END IF; + IF (SELECT count(*) FROM employment_employing_organization_record WHERE employment_record_id='20000000-0000-7000-8000-000000000020'::uuid) <> 0 THEN + RAISE EXCEPTION 'tenant alpha must not see tenant beta employer facts'; + END IF; +END $$; +SELECT set_config('orgmetra.tenant_record_id','20000000-0000-7000-8000-000000000001',false); +DO $$ BEGIN + IF (SELECT count(*) FROM employment_employing_organization_record WHERE employment_record_id='20000000-0000-7000-8000-000000000020'::uuid AND tstzrange(recorded_from, recorded_to, '[)') @> TIMESTAMPTZ '2026-09-01 00:00:00+00') <> 1 THEN + RAISE EXCEPTION 'tenant beta should see exactly one employer fact at the selected system-time coordinate'; + END IF; + IF (SELECT count(*) FROM employment_employing_organization_record WHERE employment_record_id='10000000-0000-7000-8000-000000000020'::uuid) <> 0 THEN + RAISE EXCEPTION 'tenant beta must not see tenant alpha employer facts'; + END IF; +END $$; +RESET ROLE; +SQL + +# The exact-one invariant spans Employment, employer relationships, and legal-entity +# classification. Conflicting tenant-local writes must serialize before deferred +# validation so two READ COMMITTED transactions cannot validate stale snapshots. +psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO employment_record (tenant_record_id, employment_record_id, person_record_id) +VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000024','10000000-0000-7000-8000-000000000010'); +INSERT INTO organization_unit (tenant_record_id, organization_unit_id) +VALUES ('10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000033'); +INSERT INTO organization_unit_version ( + tenant_record_id, organization_unit_version_id, organization_unit_id, + unit_name, organization_type_code, effective_from, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000043', + '10000000-0000-7000-8000-000000000033','Race Legal Employer','legal_entity', + DATE '2025-01-01',TIMESTAMPTZ '2026-01-02 00:00:00+00' +); +SQL + +race_org_log="$(mktemp)" +( + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' +BEGIN; +UPDATE organization_unit_version +SET recorded_to=TIMESTAMPTZ '2026-10-01 00:00:00+00' +WHERE organization_unit_version_id='10000000-0000-7000-8000-000000000043'; +INSERT INTO organization_unit_version ( + tenant_record_id, organization_unit_version_id, organization_unit_id, + unit_name, organization_type_code, effective_from, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000044', + '10000000-0000-7000-8000-000000000033','Race Department','department', + DATE '2025-01-01',TIMESTAMPTZ '2026-10-01 00:00:00+00' +); +SELECT pg_sleep(3); +ROLLBACK; +SQL +) >"${race_org_log}" 2>&1 & +race_org_pid=$! + +# Give the organization transaction enough time to reach its deliberate hold. +sleep 1 +set +e +race_employment_output="$(psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL' 2>&1 +BEGIN; +SET LOCAL statement_timeout = '750ms'; +INSERT INTO employment_record_version ( + tenant_record_id, employment_record_version_id, employment_record_id, + employment_status_code, employment_concurrency_code, effective_from, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000025', + '10000000-0000-7000-8000-000000000024','active','exclusive', + DATE '2026-01-01',TIMESTAMPTZ '2026-10-02 00:00:00+00' +); +INSERT INTO employment_employing_organization_record ( + tenant_record_id, employment_employing_organization_record_id, + employment_record_id, employing_organization_unit_id, effective_from, recorded_from +) VALUES ( + '10000000-0000-7000-8000-000000000001','10000000-0000-7000-8000-000000000062', + '10000000-0000-7000-8000-000000000024','10000000-0000-7000-8000-000000000033', + DATE '2026-01-01',TIMESTAMPTZ '2026-10-02 00:00:00+00' +); +COMMIT; +SQL +)" +race_employment_status=$? +set -e +wait "${race_org_pid}" + +if [[ ${race_employment_status} -eq 0 ]]; then + cat "${race_org_log}" >&2 + rm -f "${race_org_log}" + echo "expected concurrent legal-entity and active Employment mutations to serialize, but the competing Employment committed" >&2 + exit 1 +fi +if [[ "${race_employment_output}" != *"statement timeout"* ]]; then + cat "${race_org_log}" >&2 + rm -f "${race_org_log}" + echo "expected tenant-local invariant serialization wait, got: ${race_employment_output}" >&2 + exit 1 +fi +rm -f "${race_org_log}" + +echo "Employment employing-organization PostgreSQL contract passed" diff --git a/tests/test_operational_uuid_postgres.sh b/tests/test_operational_uuid_postgres.sh index d77e88588..ef61d5478 100644 --- a/tests/test_operational_uuid_postgres.sh +++ b/tests/test_operational_uuid_postgres.sh @@ -5,6 +5,7 @@ set -euo pipefail psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0001_foundation_schema.sql psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0002_sealed_evidence_digest.sql +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f database/migrations/0040_employment_employing_organization.sql uncovered_uuid_columns="$(psql "${DATABASE_URL}" -Atqc " SELECT count(*) diff --git a/tests/validate_repository.py b/tests/validate_repository.py index fe0a329ff..83c87fd77 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -55,7 +55,9 @@ "docs/adr/0012-governed-migration-handoff.md", "docs/adr/0013-governed-requisition-review-packet.md", "docs/adr/0014-job-analysis-snapshot-persistence.md", + "docs/adr/0141-employment-employing-organization.md", "docs/doctoring/REFERENCES.md", + "docs/doctoring/employment-employing-organization.md", "docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md", "docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md", "database/migrations/0001_foundation_schema.sql", @@ -71,6 +73,7 @@ "database/migrations/0011_criterion_observation_scope.sql", "database/migrations/0012_people_mutation_idempotency.sql", "database/migrations/0013_job_analysis_snapshot.sql", + "database/migrations/0040_employment_employing_organization.sql", "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", "packages/hris-kernel/tests/test_audit_outbox.py", "schemas/openapi.yaml", @@ -92,6 +95,7 @@ "tests/test_criterion_observation_scope_postgres.sh", "tests/test_people_mutation_idempotency_postgres.sh", "tests/test_job_analysis_snapshot_postgres.sh", + "tests/test_employment_employing_organization_postgres.sh", "tests/validate_repository.py", ] @@ -364,6 +368,19 @@ def _validate_database_contract() -> None: "CREATE TRIGGER job_analysis_snapshot_append_only_guard", "ALTER TABLE job_analysis_snapshot FORCE ROW LEVEL SECURITY", "CREATE POLICY job_analysis_snapshot_scope_policy", + "CREATE TABLE employment_employing_organization_record", + "employment_employing_organization_employment_tenant_fk", + "employment_employing_organization_unit_tenant_fk", + "CONSTRAINT employment_employing_organization_bitemporal_exclusion", + "CREATE FUNCTION validate_employment_employing_organization_scope", + "CREATE CONSTRAINT TRIGGER employment_employing_organization_exact_one_employment_guard", + "CREATE CONSTRAINT TRIGGER employment_employing_organization_exact_one_relationship_guard", + "CREATE CONSTRAINT TRIGGER employment_employing_organization_exact_one_organization_guard", + "CREATE TRIGGER employment_employing_organization_bitemporal_guard", + "CREATE TRIGGER employment_employing_organization_truncate_guard", + "ALTER TABLE employment_employing_organization_record FORCE ROW LEVEL SECURITY", + "CREATE POLICY employment_employing_organization_scope_policy", + "CREATE INDEX employment_employing_organization_unit_lookup_index", ] for fragment in required_fragments: if fragment not in sql: