diff --git a/.github/workflows/position-reporting-persistence-quality.yml b/.github/workflows/position-reporting-persistence-quality.yml new file mode 100644 index 000000000..a931f2edf --- /dev/null +++ b/.github/workflows/position-reporting-persistence-quality.yml @@ -0,0 +1,96 @@ +name: Position Reporting Persistence Quality + +on: + pull_request: + branches: + - develop + - feat/position-reporting-hierarchy + paths: + - "database/migrations/0020_position_reporting_relationship.sql" + - "tests/test_position_reporting_persistence_postgres.sh" + - "tests/test_position_reporting_review_binding_postgres.sh" + - "tests/test_position_reporting_concurrency_postgres.sh" + - "docs/adr/0106-position-reporting-persistence.md" + - "docs/traceability/position-reporting-persistence.md" + - "docs/doctoring/position-reporting-persistence-references.md" + - ".github/workflows/position-reporting-persistence-quality.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: position-reporting-persistence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + postgres_contract: + name: Governed position-reporting persistence contract + 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 deterministic persistence provenance + run: | + python - <<'PY' + import hashlib + import json + from pathlib import Path + + required = [ + "tests/test_position_reporting_persistence_postgres.sh", + "tests/test_position_reporting_review_binding_postgres.sh", + "tests/test_position_reporting_concurrency_postgres.sh", + ".github/workflows/position-reporting-persistence-quality.yml", + ] + optional = [ + "database/migrations/0020_position_reporting_relationship.sql", + "docs/adr/0106-position-reporting-persistence.md", + "docs/traceability/position-reporting-persistence.md", + "docs/doctoring/position-reporting-persistence-references.md", + ] + rows = [] + for path_text in required + [path for path in optional if Path(path).is_file()]: + data = Path(path_text).read_bytes() + rows.append({ + "path": path_text, + "sha256": hashlib.sha256(data).hexdigest(), + "bytes": len(data), + "lines": len(data.decode("utf-8").splitlines()), + }) + print(json.dumps(rows, separators=(",", ":"))) + PY + - name: Run position-reporting persistence regressions + run: bash tests/test_position_reporting_persistence_postgres.sh + - name: Verify immutable human-review binding + run: bash tests/test_position_reporting_review_binding_postgres.sh + - name: Verify concurrent cycle prevention + run: bash tests/test_position_reporting_concurrency_postgres.sh + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1bdc13b5a..c29d494e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -75,7 +75,7 @@ The initial deployment may share one physical PostgreSQL cluster. Logical isolat | Service identifier | Owned schema and representative tables | Database role | |---|---|---| | `people_core` | `people_core`: person, name, employment, assignment, compensation, and candidate-worker linkage records | `people_core_role` | -| `organization_core` | `organization_core`: organization units and position records | `organization_core_role` | +| `organization_core` | `organization_core`: organization units, position records, and Position reporting relations | `organization_core_role` | | `job_architecture` | `job_architecture`: job profiles, publication evidence, and persisted `job_analysis_snapshot` / task / KSAO rows | `job_architecture_role` | | `talent_acquisition` | `talent_acquisition`: candidate profiles, selection decisions, and decision evidence | `talent_acquisition_role` | | `performance_management` | `performance_management`: criterion blueprints and observations | `performance_management_role` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f4752d7..329d07335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Orgmetra will be documented in this file. - Accepted ADRs 0001–0003 now include buyer-facing Context, Decision, and Consequences grounded in verified ISO 30400:2022, ISO 30414:2025, Uniform Guidelines (29 C.F.R. Part 1607), SIOP (2018), OpenAPI Specification v3.2.0, OpenID Connect Core 1.0 errata set 2, CloudEvents v1.0.2, Jensen and Snodgrass (1999), Snodgrass (1999), and Allen (1983) records already listed in `docs/doctoring/REFERENCES.md`. ADRs 0004 and 0005 gained APA 7th References pointers to that same bibliography without changing their Decision bodies. - Active-PR governed Job Analysis persistence/API on the canonical `JobAnalysisSnapshot` model: migration `0013_job_analysis_snapshot.sql` stores immutable tenant-scoped snapshot, Task, KSAO, Task–KSAO, FJA and write-command evidence; `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots` and matching GET enforce purpose-bound Keyverse scope, authenticated-principal actor authority, bounded/strict JSON handling, transactional Idempotency-Key serialization, parent-scope fail-closed integrity, forced RLS, and atomic audit/outbox evidence. ADR 0014 records the persistence decision while ADR 0007 remains the domain/evidence authority; validated evidence still requires accountable human review and non-LLM provenance, and the service does not make a high-impact employment decision. +- Active-PR #106 Position-to-Position reporting persistence: normalized bitemporal solid-line relationship anchor/version tables with staffable endpoint coverage, tenant isolation, cycle prevention under concurrent writes, immutable human-review/application audit binding, history/TRUNCATE guards, trusted trigger search paths, and reserved UUID sentinel checks. It remains Draft stacked on PR #94 and is not protected-branch truth. - Active-PR `orgmetra_selection_review` packet for PII-minimized, evidence-bound human selection review: canonical operational tenant identity, UUID-backed opaque candidate/Job/sealed-evidence/reviewer references, explicit purpose/reason/evidence version, deterministic canonical JSON and SHA-256 correlation, mandatory human decision state, redacted packet repr, and provenance-paired model evidence that remains `untrusted_draft`, with exact 100% owned statement and branch coverage required by its quality gate. - Active performance-criterion scope hardening: `criterion_observation_scope_guard` rejects criterion outcomes for a Job the worker did not effectively hold at the observation date, observations before the relevant assignment, and observations outside the referenced performance cycle while preserving valid multiple-assignment cases and existing bitemporal correction semantics. The guard evaluates current-recorded facts, derives the date coordinate from `observed_at` in UTC so session `TimeZone` cannot alter the result, uses a trusted function search path, and adds no PII or automated employment decision authority. The Foundation PostgreSQL contract also rejects a closed `recorded_to` on each time-coordinate lookup and proves UTC midnight plus non-UTC session `TimeZone` boundaries. - Bitemporal tenant-scoped organization hierarchy validation that rejects visible indirect parent cycles and reuses single-valued recorded-time reconstruction before graph traversal. diff --git a/database/migrations/0020_position_reporting_relationship.sql b/database/migrations/0020_position_reporting_relationship.sql new file mode 100644 index 000000000..dffe80da1 --- /dev/null +++ b/database/migrations/0020_position_reporting_relationship.sql @@ -0,0 +1,446 @@ +-- Persist authoritative Position-to-Position solid-line reporting truth after +-- independent human review and authoritative application. Person, Assignment, +-- compensation, assessment, and free-form HR values remain outside this model. + +SET search_path = public, pg_catalog; + +CREATE TABLE position_reporting_relationship_record ( + tenant_record_id uuid NOT NULL REFERENCES tenant_record(tenant_record_id), + position_reporting_relationship_record_id uuid PRIMARY KEY, + subordinate_position_record_id uuid NOT NULL, + relationship_type_code text NOT NULL, + recorded_from timestamptz NOT NULL DEFAULT pg_catalog.transaction_timestamp(), + recorded_to timestamptz, + CONSTRAINT position_reporting_relationship_record_id_operational_check + CHECK (public.is_operational_uuid(position_reporting_relationship_record_id)), + CONSTRAINT position_reporting_relationship_tenant_operational_check + CHECK (public.is_operational_uuid(tenant_record_id)), + CONSTRAINT position_reporting_subordinate_operational_check + CHECK (public.is_operational_uuid(subordinate_position_record_id)), + CONSTRAINT position_reporting_subordinate_tenant_fk + FOREIGN KEY (tenant_record_id, subordinate_position_record_id) + REFERENCES position_record(tenant_record_id, position_record_id), + CONSTRAINT position_reporting_relationship_type_check + CHECK (relationship_type_code = 'solid_line'), + CONSTRAINT position_reporting_recorded_period_check + CHECK (recorded_to IS NULL OR recorded_to > recorded_from), + CONSTRAINT position_reporting_record_tenant_identity_unique + UNIQUE (tenant_record_id, position_reporting_relationship_record_id), + CONSTRAINT position_reporting_subordinate_type_unique + UNIQUE (tenant_record_id, subordinate_position_record_id, relationship_type_code) +); + +CREATE TABLE position_reporting_relationship_version ( + tenant_record_id uuid NOT NULL REFERENCES tenant_record(tenant_record_id), + position_reporting_relationship_version_id uuid PRIMARY KEY, + position_reporting_relationship_record_id uuid NOT NULL, + manager_position_record_id uuid NOT NULL, + review_evidence_digest_sha256 text NOT NULL, + application_evidence_digest_sha256 text NOT NULL, + reviewer_actor_reference text NOT NULL, + applied_by_actor_reference text NOT NULL, + reviewed_at timestamptz NOT NULL, + effective_from date NOT NULL, + effective_to date, + recorded_from timestamptz NOT NULL DEFAULT pg_catalog.transaction_timestamp(), + recorded_to timestamptz, + audit_event_record_id uuid NOT NULL, + application_state text NOT NULL DEFAULT 'applied_after_human_review', + CONSTRAINT position_reporting_version_id_operational_check + CHECK (public.is_operational_uuid(position_reporting_relationship_version_id)), + CONSTRAINT position_reporting_version_tenant_operational_check + CHECK (public.is_operational_uuid(tenant_record_id)), + CONSTRAINT position_reporting_version_record_operational_check + CHECK (public.is_operational_uuid(position_reporting_relationship_record_id)), + CONSTRAINT position_reporting_manager_operational_check + CHECK (public.is_operational_uuid(manager_position_record_id)), + CONSTRAINT position_reporting_audit_event_operational_check + CHECK (public.is_operational_uuid(audit_event_record_id)), + CONSTRAINT position_reporting_version_record_tenant_fk + FOREIGN KEY (tenant_record_id, position_reporting_relationship_record_id) + REFERENCES position_reporting_relationship_record( + tenant_record_id, + position_reporting_relationship_record_id + ), + CONSTRAINT position_reporting_manager_tenant_fk + FOREIGN KEY (tenant_record_id, manager_position_record_id) + REFERENCES position_record(tenant_record_id, position_record_id), + CONSTRAINT position_reporting_audit_tenant_fk + FOREIGN KEY (tenant_record_id, audit_event_record_id) + REFERENCES audit_event_record(tenant_record_id, audit_event_record_id), + CONSTRAINT position_reporting_review_digest_check + CHECK (review_evidence_digest_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT position_reporting_application_digest_check + CHECK (application_evidence_digest_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT position_reporting_reviewer_actor_check + CHECK ( + reviewer_actor_reference ~ + '^actor:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT position_reporting_applied_actor_check + CHECK ( + applied_by_actor_reference ~ + '^actor:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT position_reporting_actor_separation_check + CHECK (reviewer_actor_reference <> applied_by_actor_reference), + CONSTRAINT position_reporting_effective_period_check + CHECK (effective_to IS NULL OR effective_to > effective_from), + CONSTRAINT position_reporting_version_recorded_period_check + CHECK (recorded_to IS NULL OR recorded_to > recorded_from), + CONSTRAINT position_reporting_review_chronology_check + CHECK (reviewed_at <= recorded_from), + CONSTRAINT position_reporting_application_state_check + CHECK (application_state = 'applied_after_human_review'), + CONSTRAINT position_reporting_version_tenant_identity_unique + UNIQUE (tenant_record_id, position_reporting_relationship_version_id), + CONSTRAINT position_reporting_audit_event_unique + UNIQUE (tenant_record_id, audit_event_record_id), + CONSTRAINT position_reporting_bitemporal_exclusion + EXCLUDE USING gist ( + tenant_record_id WITH =, + position_reporting_relationship_record_id WITH =, + daterange(effective_from, effective_to, '[)') WITH &&, + tstzrange(recorded_from, recorded_to, '[)') WITH && + ) +); + +CREATE FUNCTION enforce_position_reporting_system_time() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + IF NEW.recorded_to IS NOT NULL THEN + RAISE EXCEPTION 'position-reporting recorded_to must be NULL on insert' + USING ERRCODE = '22023'; + END IF; + IF NEW.recorded_from IS DISTINCT FROM pg_catalog.transaction_timestamp() THEN + RAISE EXCEPTION 'position-reporting recorded_from must equal the current transaction timestamp' + USING ERRCODE = '22023'; + END IF; + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION enforce_position_reporting_system_time() IS + 'Requires PostgreSQL transaction time for new position-reporting recorded intervals and requires those intervals to begin open.'; + +CREATE TRIGGER position_reporting_record_system_time_guard +BEFORE INSERT ON position_reporting_relationship_record +FOR EACH ROW +EXECUTE FUNCTION enforce_position_reporting_system_time(); + +CREATE TRIGGER position_reporting_version_system_time_guard +BEFORE INSERT ON position_reporting_relationship_version +FOR EACH ROW +EXECUTE FUNCTION enforce_position_reporting_system_time(); + +CREATE FUNCTION protect_position_reporting_history() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'position-reporting history cannot be deleted' + USING ERRCODE = '55000'; + END IF; + + IF OLD.recorded_to IS NOT NULL + OR NEW.recorded_to IS NULL + OR NEW.recorded_to IS DISTINCT FROM pg_catalog.transaction_timestamp() + OR to_jsonb(NEW) - 'recorded_to' <> to_jsonb(OLD) - 'recorded_to' THEN + RAISE EXCEPTION 'position-reporting history may only close an open recorded interval at the current transaction timestamp' + USING ERRCODE = '55000'; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION protect_position_reporting_history() IS + 'Rejects deletion and in-place rewriting of position-reporting history; UPDATE may only close an open system-recorded interval at PostgreSQL transaction time.'; + +CREATE TRIGGER position_reporting_record_history_guard +BEFORE UPDATE OR DELETE ON position_reporting_relationship_record +FOR EACH ROW +EXECUTE FUNCTION protect_position_reporting_history(); + +CREATE TRIGGER position_reporting_version_history_guard +BEFORE UPDATE OR DELETE ON position_reporting_relationship_version +FOR EACH ROW +EXECUTE FUNCTION protect_position_reporting_history(); + +CREATE FUNCTION position_reporting_has_staffable_coverage( + checked_tenant_record_id uuid, + checked_position_record_id uuid, + checked_effective_from date, + checked_effective_to date, + checked_known_at timestamptz +) +RETURNS boolean +LANGUAGE sql +STABLE +SET search_path = pg_catalog, public, pg_temp +AS $$ + SELECT COALESCE( + pg_catalog.range_agg( + daterange(version.effective_from, version.effective_to, '[)') + ) @> daterange(checked_effective_from, checked_effective_to, '[)'), + false + ) + FROM position_record AS record + JOIN position_record_version AS version + ON version.tenant_record_id = record.tenant_record_id + AND version.position_record_id = record.position_record_id + WHERE record.tenant_record_id = checked_tenant_record_id + AND record.position_record_id = checked_position_record_id + AND record.recorded_from <= checked_known_at + AND (record.recorded_to IS NULL OR checked_known_at < record.recorded_to) + AND version.recorded_from <= checked_known_at + AND (version.recorded_to IS NULL OR checked_known_at < version.recorded_to) + AND version.position_status_code IN ('active', 'open') + AND daterange(version.effective_from, version.effective_to, '[)') && + daterange(checked_effective_from, checked_effective_to, '[)'); +$$; + +COMMENT ON FUNCTION position_reporting_has_staffable_coverage(uuid, uuid, date, date, timestamptz) IS + 'Returns true only when one tenant Position is system-visible and its active/open PositionVersion union covers the entire proposed reporting effective interval at the proposed recorded-time coordinate.'; + +CREATE FUNCTION enforce_position_reporting_scope() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +DECLARE + subordinate_position_id uuid; + relationship_type text; + anchor_recorded_to timestamptz; + audit_event jsonb; + audit_event_digest text; + outbox_found boolean; + cycle_found boolean; +BEGIN + -- Graph validity is a tenant-wide invariant. Concurrent opposite edge + -- inserts can otherwise both validate before either commits. A transaction + -- advisory lock serializes graph mutations for one tenant while still + -- allowing different tenants to progress independently. This trigger is + -- VOLATILE, so the queries after lock acquisition use current committed + -- state under PostgreSQL READ COMMITTED semantics. + PERFORM pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended( + 'orgmetra_position_reporting:' || NEW.tenant_record_id::text, + 0 + ) + ); + + SELECT subordinate_position_record_id, relationship_type_code, recorded_to + INTO subordinate_position_id, relationship_type, anchor_recorded_to + FROM position_reporting_relationship_record + WHERE tenant_record_id = NEW.tenant_record_id + AND position_reporting_relationship_record_id = NEW.position_reporting_relationship_record_id + FOR SHARE; + + IF NOT FOUND OR anchor_recorded_to IS NOT NULL THEN + RAISE EXCEPTION 'position-reporting version requires an open same-tenant relationship anchor' + USING ERRCODE = '23514'; + END IF; + IF relationship_type <> 'solid_line' THEN + RAISE EXCEPTION 'position-reporting persistence supports only solid-line relationships' + USING ERRCODE = '23514'; + END IF; + IF subordinate_position_id = NEW.manager_position_record_id THEN + RAISE EXCEPTION 'a Position cannot report to itself' + USING ERRCODE = '23514'; + END IF; + + IF NOT position_reporting_has_staffable_coverage( + NEW.tenant_record_id, + subordinate_position_id, + NEW.effective_from, + NEW.effective_to, + NEW.recorded_from + ) OR NOT position_reporting_has_staffable_coverage( + NEW.tenant_record_id, + NEW.manager_position_record_id, + NEW.effective_from, + NEW.effective_to, + NEW.recorded_from + ) THEN + RAISE EXCEPTION 'position-reporting endpoints require staffable PositionVersion coverage for the entire effective interval' + USING ERRCODE = '23514'; + END IF; + + WITH RECURSIVE manager_path(position_record_id, effective_period) AS ( + SELECT + NEW.manager_position_record_id, + daterange(NEW.effective_from, NEW.effective_to, '[)') + UNION + SELECT + next_version.manager_position_record_id, + manager_path.effective_period * + daterange(next_version.effective_from, next_version.effective_to, '[)') + FROM manager_path + JOIN position_reporting_relationship_record AS next_record + ON next_record.tenant_record_id = NEW.tenant_record_id + AND next_record.subordinate_position_record_id = manager_path.position_record_id + AND next_record.relationship_type_code = 'solid_line' + AND next_record.recorded_to IS NULL + JOIN position_reporting_relationship_version AS next_version + ON next_version.tenant_record_id = next_record.tenant_record_id + AND next_version.position_reporting_relationship_record_id = + next_record.position_reporting_relationship_record_id + AND next_version.recorded_to IS NULL + WHERE manager_path.effective_period && + daterange(next_version.effective_from, next_version.effective_to, '[)') + ) + SELECT EXISTS ( + SELECT 1 + FROM manager_path + WHERE position_record_id = subordinate_position_id + ) INTO cycle_found; + + IF cycle_found THEN + RAISE EXCEPTION 'position-reporting relationship would create a management cycle' + USING ERRCODE = '23514'; + END IF; + + SELECT canonical_event_json::jsonb, event_envelope_digest + INTO audit_event, audit_event_digest + FROM audit_event_record + WHERE tenant_record_id = NEW.tenant_record_id + AND audit_event_record_id = NEW.audit_event_record_id + FOR SHARE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'position-reporting version requires immutable same-tenant audit evidence' + USING ERRCODE = '23514'; + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM outbox_delivery_record + WHERE tenant_record_id = NEW.tenant_record_id + AND audit_event_record_id = NEW.audit_event_record_id + AND delivery_target_code = 'integration_hub' + ) INTO outbox_found; + + IF NOT outbox_found THEN + RAISE EXCEPTION 'position-reporting version requires transactional audit/outbox evidence' + USING ERRCODE = '23514'; + END IF; + + IF audit_event ->> 'orgmetrapurpose' + IS DISTINCT FROM 'position_reporting_change_apply' + OR audit_event ->> 'orgmetrareason' + IS DISTINCT FROM 'approved_reporting_line_change' + OR audit_event ->> 'orgmetraactor' + IS DISTINCT FROM NEW.applied_by_actor_reference + OR audit_event ->> 'orgmetraevidence' + IS DISTINCT FROM NEW.review_evidence_digest_sha256 + OR audit_event_digest IS DISTINCT FROM NEW.application_evidence_digest_sha256 + OR audit_event ->> 'subject' + IS DISTINCT FROM + 'position_reporting_relationship:' || NEW.position_reporting_relationship_record_id::text + OR audit_event #>> '{data,result_code}' + IS DISTINCT FROM 'position_reporting_applied' + OR (audit_event #>> '{data,high_impact}')::boolean IS DISTINCT FROM false + OR (audit_event ->> 'time')::timestamptz < NEW.reviewed_at + OR (audit_event ->> 'time')::timestamptz > NEW.recorded_from THEN + RAISE EXCEPTION 'position-reporting audit evidence does not exactly match the applied relationship scope' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION enforce_position_reporting_scope() IS + 'Before persistence, serializes one tenant reporting graph, resolves the same-tenant relationship anchor, requires staffable subordinate/manager PositionVersion coverage for the full effective interval, rejects self-reporting and cycles over overlapping effective time, and requires immutable audit/outbox application evidence that binds the governed reason, reviewed evidence digest, applying actor, exact application event digest, subject, result, and chronology.'; + +CREATE TRIGGER position_reporting_version_scope_guard +BEFORE INSERT ON position_reporting_relationship_version +FOR EACH ROW +EXECUTE FUNCTION enforce_position_reporting_scope(); + +CREATE FUNCTION enforce_position_reporting_anchor_alignment() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + IF NEW.recorded_to IS NULL OR NEW.recorded_to IS NOT DISTINCT FROM OLD.recorded_to THEN + RETURN NULL; + END IF; + + IF EXISTS ( + SELECT 1 + FROM position_reporting_relationship_version AS version + WHERE version.tenant_record_id = NEW.tenant_record_id + AND version.position_reporting_relationship_record_id = + NEW.position_reporting_relationship_record_id + AND (version.recorded_to IS NULL OR version.recorded_to > NEW.recorded_to) + ) THEN + RAISE EXCEPTION 'cannot close position-reporting anchor while a recorded version remains open' + USING ERRCODE = '23514'; + END IF; + RETURN NULL; +END; +$$; + +COMMENT ON FUNCTION enforce_position_reporting_anchor_alignment() IS + 'Deferred anchor-closure guard: every relationship version must be recorded closed no later than its durable anchor before commit.'; + +CREATE CONSTRAINT TRIGGER position_reporting_anchor_alignment_guard +AFTER UPDATE ON position_reporting_relationship_record +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION enforce_position_reporting_anchor_alignment(); + +CREATE FUNCTION reject_position_reporting_truncate() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + RAISE EXCEPTION 'position-reporting history cannot be truncated' + USING ERRCODE = '55000'; +END; +$$; + +COMMENT ON FUNCTION reject_position_reporting_truncate() IS + 'Rejects table-wide TRUNCATE so position-reporting history cannot bypass row-level bitemporal guards.'; + +CREATE TRIGGER position_reporting_record_truncate_guard +BEFORE TRUNCATE ON position_reporting_relationship_record +FOR EACH STATEMENT +EXECUTE FUNCTION reject_position_reporting_truncate(); + +CREATE TRIGGER position_reporting_version_truncate_guard +BEFORE TRUNCATE ON position_reporting_relationship_version +FOR EACH STATEMENT +EXECUTE FUNCTION reject_position_reporting_truncate(); + +REVOKE TRUNCATE ON position_reporting_relationship_record FROM PUBLIC; +REVOKE TRUNCATE ON position_reporting_relationship_version FROM PUBLIC; + +ALTER TABLE position_reporting_relationship_record ENABLE ROW LEVEL SECURITY; +ALTER TABLE position_reporting_relationship_record FORCE ROW LEVEL SECURITY; +CREATE POLICY position_reporting_record_scope_policy +ON position_reporting_relationship_record +USING (tenant_record_id = current_tenant_record_id()) +WITH CHECK (tenant_record_id = current_tenant_record_id()); + +ALTER TABLE position_reporting_relationship_version ENABLE ROW LEVEL SECURITY; +ALTER TABLE position_reporting_relationship_version FORCE ROW LEVEL SECURITY; +CREATE POLICY position_reporting_version_scope_policy +ON position_reporting_relationship_version +USING (tenant_record_id = current_tenant_record_id()) +WITH CHECK (tenant_record_id = current_tenant_record_id()); + +COMMENT ON TABLE position_reporting_relationship_record IS + 'Durable tenant-scoped Position-to-Position solid-line relationship anchor. The subordinate seat and relationship type are stable anchor identity; Person and Assignment are intentionally absent.'; + +COMMENT ON TABLE position_reporting_relationship_version IS + 'Authoritative bitemporal manager-Position versions applied only after separate human review and immutable audit/outbox evidence. Both Position endpoints must remain staffable for the full effective interval at the recorded-time coordinate. The application audit event must bind the governed reason, review digest, and its exact envelope digest. The relation stores no worker PII, compensation, ratings, or employment-decision output.'; diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 7d4afd563..4ad8572fa 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -14,6 +14,8 @@ | `job_profile_version` | Bitemporal title, family, and version definition for a job profile. | | `position_record` | Durable seat identity that keeps stable organization and job references. | | `position_record_version` | Bitemporal position status and effective period. | +| `position_reporting_relationship_record` | Tenant-scoped durable Position-to-Position solid-line relationship anchor. | +| `position_reporting_relationship_version` | Reviewed bitemporal manager-Position application bound to immutable audit/outbox evidence. | | `assignment_record` | A person's allocation to a position through one employment. | | `candidate_profile` | Applicant/candidate record before hire. | | `candidate_worker_link` | Legacy append-only candidate-to-worker linkage retained for historical reads; new writes use `candidate_worker_conversion_record`. | @@ -52,6 +54,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. +Active PR #106 adds `position_reporting_relationship_record` as the stable subordinate-Position/relationship-type anchor and `position_reporting_relationship_version` as the changing manager-Position fact. Both are tenant-qualified and bitemporal; the version requires staffable PositionVersion coverage across its full effective interval plus separate human-review and application evidence. The relation stores no Person, Assignment, compensation, rating, or employment-decision output. + 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..54d1e7543 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -18,6 +18,10 @@ erDiagram job_profile ||--o{ job_profile_version : has_versions job_profile ||--o{ position_record : defines position_record ||--o{ position_record_version : has_versions + position_record ||--o{ position_reporting_relationship_record : subordinate + position_reporting_relationship_record ||--o{ position_reporting_relationship_version : has_versions + position_record ||--o{ position_reporting_relationship_version : manager + audit_event_record ||--o| position_reporting_relationship_version : proves_application employment_record ||--o{ assignment_record : covers person_record ||--o{ assignment_record : receives position_record ||--o{ assignment_record : assigned_through @@ -49,6 +53,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. +Active PR #106 adds a Position-to-Position reporting anchor and reviewed manager-Position version. It is independent of worker occupancy and Assignment state; its effective/system intervals, staffable endpoint coverage, audit binding, cycle guard, and tenant isolation are enforced by the proposed migration until the PR is integrated and retargeted. + 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/TRACEABILITY.md b/docs/TRACEABILITY.md index 22a4178fe..76699e0c6 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -10,6 +10,7 @@ | 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 | | 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 | +| Reviewed Position-to-Position reporting truth | Organization core | `position_reporting_relationship_record`, `position_reporting_relationship_version` | PostgreSQL staffable-coverage, bitemporal, audit/outbox binding, RLS, history, single-session cycle, and concurrent-cycle contracts in active PR #106 | ADR-0106 | implemented_on_active_pr | | 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 | | Governed candidate-to-worker conversion | Talent Acquisition / People core | `candidate_worker_conversion_record` with candidate, person, employment, selection decision, audit event and outbox evidence | PostgreSQL exact hire/evidence/audit-envelope binding, correction provenance, tenant RLS, legacy-write rejection and bitemporal history contract | ADR-0001, ADR-0003, ADR-0006 | implemented_on_protected_main | diff --git a/docs/adr/0106-position-reporting-persistence.md b/docs/adr/0106-position-reporting-persistence.md new file mode 100644 index 000000000..acb82a552 --- /dev/null +++ b/docs/adr/0106-position-reporting-persistence.md @@ -0,0 +1,52 @@ +# ADR 0106 — Bitemporal Position reporting persistence + +- Status: Proposed +- Active PR: #106; not protected-main truth +- **Decision date:** 2026-08-24 +- **Parent contract:** PR #94, `feat/position-reporting-hierarchy@3f67182bb3065f2fc8fd974bfdd75a390d8a8fdc` + +## Context + +Protected `develop` separates Job, Position, Assignment, and organization-unit hierarchy but does not persist Position-to-Position supervisory relationships. PR #94 adds an in-memory bitemporal solid-line reporting snapshot and explicitly leaves persistence as later work. A commercial HRIS needs durable relationship truth without deriving a manager from the worker currently occupying a Position or from the organization-unit tree. + +## Decision + +Orgmetra persists a solid-line reporting relationship as a normalized anchor/version pair: + +- `position_reporting_relationship_record` owns stable tenant, subordinate Position, and relationship type identity. +- `position_reporting_relationship_version` owns manager Position plus effective/business and recorded/system time for each reviewed application. +- one `(tenant, subordinate Position, relationship type)` anchor exists, so manager changes are versions rather than duplicate relationship identities; +- the version table carries only evidence needed to prove reviewed application: a SHA-256 review-evidence digest, the exact SHA-256 digest of the immutable application audit envelope, pseudonymous reviewer and applying actors, review time, audit correlation, and fixed application state; +- Person, Assignment, worker identity, compensation, performance rating, assessment output, and free-form HR text are not columns in this relation. + +New recorded intervals use PostgreSQL `transaction_timestamp()` and begin open. History cannot be rewritten or deleted; the only row update is closing one open recorded interval at the transaction timestamp. `TRUNCATE` is rejected separately. Both relations use `ENABLE ROW LEVEL SECURITY` plus `FORCE ROW LEVEL SECURITY` and tenant policies based on `current_tenant_record_id()`. + +A version insert fails closed unless it has an open same-tenant anchor, a different reviewer and applying actor, immutable same-tenant audit/outbox evidence for purpose `position_reporting_change_apply` with governed reason `approved_reporting_line_change`, no self-reporting edge, and no management cycle over an overlapping effective period. The application audit event must carry the exact reviewed-evidence digest in `orgmetraevidence`; the version's application-evidence digest must equal the persisted audit envelope digest. This prevents a syntactically valid reviewer/digest pair—or a semantically unrelated audit reason—from being recorded as evidence for the reporting-line application. Composite foreign keys bind both subordinate and manager Position IDs to the same tenant. + +The database also enforces the staffable endpoint contract already defined by parent PR #94. At the proposed relationship version's system-recorded coordinate, both the subordinate and manager Position anchors must be visible and the union of same-tenant `active`/`open` `position_record_version` effective ranges must cover the relationship's entire effective interval. PostgreSQL `range_agg(daterange(...))` produces a normalized date multirange, and multirange containment proves full coverage without enumerating days or incorrectly rejecting legitimate contiguous active/open Position versions. A stable Position record without staffable bitemporal PositionVersion coverage is therefore insufficient persistence evidence. + +Graph acyclicity is tenant-wide and cannot be protected by independent row constraints alone. Before a version trigger reads the graph, it obtains a transaction-scoped PostgreSQL advisory lock keyed from the tenant UUID. Opposite concurrent mutations for one tenant are therefore serialized, while different tenants can proceed independently. The PL/pgSQL trigger remains the default `VOLATILE`; PostgreSQL documents that a `VOLATILE` function obtains a fresh snapshot for each SQL query it executes. After a waiting transaction acquires the tenant graph lock, its recursive graph query therefore sees the relationship committed by the preceding lock holder and rejects the opposite edge as a cycle. Hash-key collisions can only over-serialize unrelated tenants; they cannot relax the invariant. + +## Why this shape + +The anchor/version split keeps stable relationship identity separate from changing manager/effective-time facts and therefore remains 3NF while supporting bitemporal correction. PostgreSQL exclusion/range semantics prevent overlapping business/system versions under one relationship identity; the insert guard performs cross-row cycle validation that a row-local `CHECK` constraint cannot express. RLS is defense in depth rather than authorization by itself: the application role must remain `NOSUPERUSER NOBYPASSRLS`, and high-level mutation authority remains outside this migration. + +The staffable coverage check deliberately validates the full reporting interval rather than only its start date. Otherwise a relationship could be stored as authoritative while one endpoint has no active/open PositionVersion for later days that the relationship itself claims to cover. The database check therefore matches the descriptive snapshot semantics instead of relying on downstream readers to discover and reject internally inconsistent persisted truth. + +The audit binding deliberately uses the already immutable `audit_event_record` envelope as the application evidence instead of trusting an unrelated caller-provided digest. The event identifies the applying actor, tenant, relationship subject, governed purpose and reason, result, time, and reviewed-evidence digest; storing its exact envelope digest makes the relationship version cryptographically correlate to that immutable application fact. + +The tenant advisory lock is intentionally narrow: it protects only graph mutation validation and is transaction-scoped. It does not replace transaction boundaries, RLS, authorization, or history guards. Its purpose is to make cycle validation defensible under concurrent writes rather than merely correct in single-session tests. + +## Integration and stack boundary + +This PR is a Draft descendant of #94 and cannot inherit #94 checks or reviews. It must not merge before #94. After #94 is integrated, retarget #106 to the fresh protected `develop`, reconcile any migration-number/document conflicts, and re-run full exact-head CI/security/recovery evidence. + +PR #95 owns the in-memory pre-mutation review packet. This persistence slice does not copy or modify that branch; it accepts the review digest plus immutable application audit evidence as the handoff boundary. A later authorized host adapter may translate a verified review packet into the database command, but direct cross-service SQL is out of scope. + +Canonical `docs/DATA_MODEL.md` / `docs/ERD.md` now record the proposed active-PR relationship tables and explicitly distinguish them from protected-main truth. Integration must reconcile the accepted relationship tables and remove the active-PR qualification after dependency ordering is resolved. + +## Consequences + +The database can preserve audited supervisory hierarchy truth independently of current worker occupancy. Reads can combine persisted relationships with PR #94's staffable-Position snapshot semantics, and persistence now rejects an edge whose PositionVersion evidence cannot support every day that the edge claims to cover. The stricter model rejects ambiguous duplicate anchors, self-reporting, single-session and concurrent cycles, caller-backdated system time, mutation of history, tenant-crossing references, non-staffable endpoint coverage, and audit evidence whose governed reason does not match the reviewed reporting-line application rather than silently repairing them. + +This ADR does not claim certification, branch-protection enforcement, release readiness, or authorization to make employment decisions. diff --git a/docs/adr/README.md b/docs/adr/README.md index 099a21139..64da6f68e 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 | +| [0106](0106-position-reporting-persistence.md) | Bitemporal Position reporting persistence | Proposed | diff --git a/docs/doctoring/position-reporting-persistence-references.md b/docs/doctoring/position-reporting-persistence-references.md new file mode 100644 index 000000000..8eb23b03c --- /dev/null +++ b/docs/doctoring/position-reporting-persistence-references.md @@ -0,0 +1,32 @@ +# Position reporting persistence — primary references + +Checked against current final PostgreSQL 16 documentation on 2026-08-24. These references justify database mechanics only; they do not imply certification or a vendor-specific HR domain standard. + +## APA 7 references + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: CREATE POLICY*. https://www.postgresql.org/docs/16/sql-createpolicy.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Constraints*. https://www.postgresql.org/docs/16/ddl-constraints.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Range/multirange functions and operators*. https://www.postgresql.org/docs/16/functions-range.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Aggregate functions*. https://www.postgresql.org/docs/16/functions-aggregate.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: ALTER TABLE*. https://www.postgresql.org/docs/16/sql-altertable.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Function security*. https://www.postgresql.org/docs/16/perm-functions.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Function volatility categories*. https://www.postgresql.org/docs/16/xfunc-volatility.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: System administration functions*. https://www.postgresql.org/docs/16/functions-admin.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 16 documentation: Visibility of data changes*. https://www.postgresql.org/docs/16/spi-visibility.html + +## Decision notes + +- PostgreSQL row-security policies use `USING` for row visibility and `WITH CHECK` for inserted/updated rows; policies only apply when row security is enabled. Orgmetra therefore uses both `ENABLE ROW LEVEL SECURITY` and `FORCE ROW LEVEL SECURITY` on the two owned relationship relations, with a tenant-context policy. Production application roles must still be `NOSUPERUSER NOBYPASSRLS`. +- Exclusion constraints are appropriate for preventing two bitemporal versions under the same durable relationship identity from simultaneously overlapping in effective and recorded time. PostgreSQL range types provide half-open date/timestamp intervals for this purpose. +- PostgreSQL documents `range_agg(anyrange)` as the union of non-null input ranges returned as a multirange, and multirange containment supports asking whether that union contains one target range. Orgmetra uses this to prove that system-visible same-tenant `active`/`open` PositionVersion intervals cover the reporting relationship's entire effective range without iterating individual dates. The check also requires the stable Position anchor itself to be visible at the same recorded-time coordinate. +- Cross-row cycle detection cannot be expressed as an ordinary row-local `CHECK`. The migration uses a narrow PL/pgSQL insert guard over trusted Orgmetra-owned tables, explicit tenant predicates, and effective-period intersection. PostgreSQL function-security guidance is why the function does not use caller-controlled dynamic SQL or an unsafe mutable `search_path`. +- A single-session cycle check is insufficient under concurrent opposite mutations because each transaction could validate before the other commits. The trigger therefore takes a transaction-scoped advisory lock keyed from the tenant before querying the reporting graph. PostgreSQL documents that transaction advisory locks are held to transaction end, and that standard procedural `VOLATILE` functions execute their SQL commands in read-write SPI mode with a fresh snapshot for each query. Consequently, a waiter performs its graph lookup after the preceding lock holder commits and can observe/reject the newly created cycle. The dedicated concurrency regression proves this behavior against PostgreSQL rather than relying on the documentation alone. +- `FORCE ROW LEVEL SECURITY` is defense in depth, not an authorization substitute. Human-review and application evidence are separately bound into immutable audit/outbox correlation before a relationship version is accepted. The application event's `orgmetraevidence` is the reviewed-evidence digest, and the persisted application digest must equal the immutable audit envelope digest. diff --git a/docs/traceability/position-reporting-persistence.md b/docs/traceability/position-reporting-persistence.md new file mode 100644 index 000000000..8bb0d98db --- /dev/null +++ b/docs/traceability/position-reporting-persistence.md @@ -0,0 +1,35 @@ +# Position reporting persistence traceability + +## Truth status + +- **Protected-main truth:** `develop@9e3e4847510e1e612b48474ba42b177b8ed824df` has no persisted Position-to-Position reporting relation. +- **Parent active PR:** #94 adds bitemporal in-memory reporting reconstruction at `3f67182bb3065f2fc8fd974bfdd75a390d8a8fdc`. +- **This active PR:** #106 persists reviewed solid-line Position relationships and remains a Draft stacked descendant until #94 integrates and this branch is retargeted/revalidated. +- **Separate active owner:** #95 owns the in-memory pre-mutation review packet. #106 consumes only the reviewed-evidence digest plus immutable application audit correlation and does not rewrite #95. + +## Requirement → implementation → executable evidence + +| Requirement | Implementation | Evidence | +| --- | --- | --- | +| Position-to-Position, never Person-to-Person | `position_reporting_relationship_record.subordinate_position_record_id` and version `manager_position_record_id`; no Person/Assignment columns | `tests/test_position_reporting_persistence_postgres.sh` valid insert and schema contract | +| One durable solid-line identity per subordinate | unique `(tenant_record_id, subordinate_position_record_id, relationship_type_code)` anchor | valid insert plus duplicate-anchor failure supplied by database unique constraint | +| Effective/business and recorded/system time remain separate | version `effective_from/effective_to`; anchor/version `recorded_from/recorded_to` | backdated-system-time regression; bitemporal exclusion | +| System-recorded time is database-owned | `enforce_position_reporting_system_time()` requires `recorded_from = transaction_timestamp()` and open `recorded_to` | caller-backdated anchor regression | +| Same-tenant subordinate and manager | composite FKs to `position_record(tenant_record_id, position_record_id)` plus FORCE RLS | schema FK and non-bypass tenant reader regression | +| Both endpoints are staffable for the full reporting interval | `position_reporting_has_staffable_coverage(...)` resolves system-visible Position anchors and uses `range_agg(daterange(...))` over same-tenant `active`/`open` PositionVersion rows; the resulting multirange must contain the entire reporting effective range | regression first attempts persistence before any PositionVersion exists and requires `staffable PositionVersion coverage`; valid fixture succeeds only after active/open endpoint versions are inserted | +| No self-reporting | `enforce_position_reporting_scope()` | self-report PostgreSQL regression | +| No effective-time management cycle in one session | recursive effective-period intersection in `enforce_position_reporting_scope()` | A→B then B→A PostgreSQL regression | +| Concurrent opposite graph mutations cannot both commit | transaction-scoped tenant advisory lock is acquired before the VOLATILE trigger's graph queries | `tests/test_position_reporting_concurrency_postgres.sh` holds X→Y open while Y→X races; exactly one edge may commit | +| Human review is distinct from applying actor | exact actor-format checks plus `reviewer_actor_reference <> applied_by_actor_reference` | database constraints and valid separated actors fixture | +| Reviewed evidence is immutable application evidence, not an unattested column | application audit `orgmetraevidence` must equal `review_evidence_digest_sha256`; stored application digest must equal `audit_event_record.event_envelope_digest` | `tests/test_position_reporting_review_binding_postgres.sh` | +| Applied truth requires immutable audit/outbox evidence with the governed reason | scope guard verifies purpose `position_reporting_change_apply`, reason `approved_reporting_line_change`, applying actor, reviewed-evidence digest, exact audit-envelope digest, subject, result code, review/application chronology, and integration-hub outbox | valid `record_audit_outbox_event(...)` fixture plus wrong-reason regression in `tests/test_position_reporting_persistence_postgres.sh`; mismatched evidence fails closed | +| Historical truth cannot be rewritten/deleted | `protect_position_reporting_history()` | manager rewrite regression | +| Table-wide destruction cannot bypass row guards | explicit BEFORE TRUNCATE guards and revoked PUBLIC TRUNCATE | TRUNCATE regression | +| Tenant isolation is enforced for ordinary app/read roles | `ENABLE` + `FORCE ROW LEVEL SECURITY`; tenant policy on both relations | `NOSUPERUSER NOBYPASSRLS` reader sees alpha=1, beta=0, missing-context=0 | +| Exact candidate execution | dedicated workflow checks out `${{ github.event.pull_request.head.sha }}` | `.github/workflows/position-reporting-persistence-quality.yml` | + +## Non-goals and buyer-safe interpretation + +Persisted reporting hierarchy describes Position structure. It does not prove which worker occupies a seat, does not create/modify Assignment, does not imply performance/compensation authority, and is not an employment decision. PR #94 remains the descriptive snapshot contract for staffable endpoint interpretation at a requested bitemporal coordinate; #106 now rejects persistence that could not satisfy that staffable Position contract across the relationship's own effective interval. + +The database relation is authoritative only after the application boundary supplies valid reviewed application evidence. RLS does not replace purpose-bound authorization. Production roles must remain non-superuser and `NOBYPASSRLS`; application mutation authority, API exposure, accessible organization-chart UI, migration/rollback choreography, and release integration remain separate bounded work. diff --git a/manifest.json b/manifest.json index 97f2bab14..5d940006e 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":"7415e8a9cdc26ec3e0b083939f0f35c9573cb721fce7f3d1333dc85045d3c677","bytes":7895,"lines":107},{"path":"CHANGELOG.md","sha256":"3011688169a6305e372570e0c81c366cda06687fa975fd5b358485060855822d","bytes":17728,"lines":77},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"database/migrations/0020_position_reporting_relationship.sql","sha256":"233fedd8038154db0532478c81b28858c541077a6b91666f6ae21cf803935bb0","bytes":20613,"lines":446},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"b4c943857ef141ce9300987962469e0322188ac830ccc1132190ce377862abf3","bytes":14116,"lines":89},{"path":"docs/ERD.md","sha256":"2dfa3d57a28e141b4574b1afb3ba449516115ac1f3bcb2dd08f54d3d1c4e5381","bytes":7669,"lines":76},{"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":"0361cc07d38017e8e5979c4a877142de5e63700496d8528090e9a042ae2d6357","bytes":11808,"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/README.md","sha256":"23bf375a9fb8f768cf290281700e836275c2080be0e9b7c697c22187d8d4df9a","bytes":1944,"lines":19},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"bd32ccc59edd82ee78aa48ad3cf4610a10ca9d327f0ab470de20eacc634528dc","bytes":28595,"lines":696},{"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":"6b7dd6d193bb5d5b773dbad2c8c07a4beaeca847c8bed67b8620171f524f53a7","bytes":15172,"lines":394},{"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_position_reporting_concurrency_postgres.sh","sha256":"a894e4f764c3355950106af040336636c10efbc612891951e13da88d9b1044df","bytes":6845,"lines":170},{"path":"tests/test_position_reporting_persistence_postgres.sh","sha256":"148b270c9feb97bda2936b19436caa462f79b9e0597082ec03e274d792068120","bytes":13998,"lines":314},{"path":"tests/test_position_reporting_review_binding_postgres.sh","sha256":"f2a217d00de0e99f1d7f1f8c7b3423f182cc91311c90fc3053315d59e22aa211","bytes":1524,"lines":33},{"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":"351edc968270d7bca81059e0ef21d5216ca6ea8a405aa45eb6d08aa71b5fa422","bytes":27545,"lines":642}]} diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 1e9fb267c..563ad9d7e 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -68,6 +68,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/0020_position_reporting_relationship.sql', 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', 'packages/hris-kernel/tests/test_audit_outbox.py', 'schemas/openapi.yaml', @@ -89,6 +90,9 @@ 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_position_reporting_persistence_postgres.sh', + 'tests/test_position_reporting_review_binding_postgres.sh', + 'tests/test_position_reporting_concurrency_postgres.sh', 'tests/validate_repository.py' ]); @@ -112,6 +116,7 @@ export const DATABASE_OBJECT_NAMES = Object.freeze([ 'organization_unit', 'organization_relation', 'business_location', 'cost_center_record', 'job_family', 'job_profile', 'job_profile_version', 'position_record', 'position_record_version', 'position_relation', + 'position_reporting_relationship_record', 'position_reporting_relationship_version', 'assignment_record', 'job_analysis_snapshot', 'job_analysis_task_item', 'job_analysis_ksao_item', 'job_analysis_task_ksao_link', 'job_analysis_write_command', @@ -139,7 +144,9 @@ 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', + 'position_reporting_relationship_record', + 'position_reporting_relationship_version' ]); 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; diff --git a/tests/foundation-contract.test.mjs b/tests/foundation-contract.test.mjs index 72b18466f..d52166f6b 100644 --- a/tests/foundation-contract.test.mjs +++ b/tests/foundation-contract.test.mjs @@ -53,6 +53,14 @@ function writeMigrationBackedTables(root) { 'CREATE TABLE job_analysis_write_command (tenant_record_id uuid NOT NULL);' ].join('\n') + '\n' ); + write( + root, + 'database/migrations/0020_position_reporting_relationship.sql', + [ + 'CREATE TABLE position_reporting_relationship_record (tenant_record_id uuid NOT NULL);', + 'CREATE TABLE position_reporting_relationship_version (tenant_record_id uuid NOT NULL);' + ].join('\n') + '\n' + ); } function makeMinimalValidFoundation(root) { diff --git a/tests/test_position_reporting_concurrency_postgres.sh b/tests/test_position_reporting_concurrency_postgres.sh new file mode 100644 index 000000000..4677f49ee --- /dev/null +++ b/tests/test_position_reporting_concurrency_postgres.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" +TENANT_ID="10000000-0000-7000-8000-000000000001" +ORG_ID="00000000-0000-7000-8000-000000000011" +JOB_ID="00000000-0000-7000-8000-000000000021" +POSITION_X="00000000-0000-7000-8000-000000000081" +POSITION_Y="00000000-0000-7000-8000-000000000082" +REL_X="00000000-0000-7000-8000-000000000083" +REL_Y="00000000-0000-7000-8000-000000000084" +VERSION_X="00000000-0000-7000-8000-000000000085" +VERSION_Y="00000000-0000-7000-8000-000000000086" +AUDIT_X="00000000-0000-4000-8000-000000000087" +AUDIT_Y="00000000-0000-4000-8000-000000000088" +OUTBOX_X="00000000-0000-4000-8000-000000000089" +OUTBOX_Y="00000000-0000-4000-8000-000000000090" +REVIEWER_X="actor:00000000-0000-4000-8000-000000000091" +REVIEWER_Y="actor:00000000-0000-4000-8000-000000000092" +APPLIER_X="actor:00000000-0000-4000-8000-000000000093" +APPLIER_Y="actor:00000000-0000-4000-8000-000000000094" +POSITION_X_VERSION="00000000-0000-7000-8000-000000000095" +POSITION_Y_VERSION="00000000-0000-7000-8000-000000000096" +REVIEW_DIGEST_X="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +REVIEW_DIGEST_Y="dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + +with_tenant() { + local tenant="$1" + shift + PGOPTIONS="-c orgmetra.tenant_record_id=${tenant}" command psql "$@" +} + +with_tenant "${TENANT_ID}" "${DATABASE_URL}" -v ON_ERROR_STOP=1 <"${log_x}" 2>&1) & +pid_x=$! +sleep 0.5 +with_tenant "${TENANT_ID}" "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "${sql_y}" >"${log_y}" 2>&1 +status_y=$? +wait "${pid_x}" +status_x=$? +set -e + +if ! ( + [[ ${status_x} -eq 0 && ${status_y} -ne 0 && "$(cat "${log_y}")" == *"cycle"* ]] || + [[ ${status_y} -eq 0 && ${status_x} -ne 0 && "$(cat "${log_x}")" == *"cycle"* ]] +); then + echo "concurrent opposite reporting mutations did not leave exactly one successful edge and one cycle rejection:" >&2 + echo "X status=${status_x}" >&2 + cat "${log_x}" >&2 + echo "Y status=${status_y}" >&2 + cat "${log_y}" >&2 + exit 1 +fi + +committed_count="$(with_tenant "${TENANT_ID}" "${DATABASE_URL}" -Atqc " +SELECT count(*) +FROM position_reporting_relationship_version +WHERE position_reporting_relationship_version_id IN ('${VERSION_X}'::uuid, '${VERSION_Y}'::uuid); +")" +if [[ "${committed_count}" != "1" ]]; then + echo "concurrent reporting mutation did not leave exactly one non-cyclic committed edge: ${committed_count}" >&2 + exit 1 +fi + +echo "position-reporting concurrent cycle prevention passed" diff --git a/tests/test_position_reporting_persistence_postgres.sh b/tests/test_position_reporting_persistence_postgres.sh new file mode 100644 index 000000000..a77d19453 --- /dev/null +++ b/tests/test_position_reporting_persistence_postgres.sh @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +for migration in \ + database/migrations/0001_foundation_schema.sql \ + database/migrations/0002_sealed_evidence_digest.sql \ + database/migrations/0003_audit_outbox_persistence.sql \ + database/migrations/0020_position_reporting_relationship.sql; do + if [[ ! -f "${migration}" ]]; then + echo "required position-reporting persistence migration is missing: ${migration}" >&2 + exit 1 + fi + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +guarded_function_count="$(psql "${DATABASE_URL}" -Atqc " +SELECT count(*) +FROM pg_proc AS function_record +JOIN pg_namespace AS namespace_record + ON namespace_record.oid = function_record.pronamespace +WHERE namespace_record.nspname = 'public' + AND function_record.proname IN ( + 'enforce_position_reporting_system_time', + 'protect_position_reporting_history', + 'position_reporting_has_staffable_coverage', + 'enforce_position_reporting_scope', + 'enforce_position_reporting_anchor_alignment', + 'reject_position_reporting_truncate' + ) + AND function_record.proconfig @> + ARRAY['search_path=pg_catalog, public, pg_temp']::text[]; +")" +if [[ "${guarded_function_count}" != "6" ]]; then + echo "position-reporting governance functions are not all pinned to the trusted search_path: ${guarded_function_count}" >&2 + exit 1 +fi + +TENANT_ID="10000000-0000-7000-8000-000000000001" +OTHER_TENANT_ID="20000000-0000-7000-8000-000000000002" +ORG_ID="00000000-0000-7000-8000-000000000011" +JOB_ID="00000000-0000-7000-8000-000000000021" +SUBORDINATE_POSITION_ID="00000000-0000-7000-8000-000000000031" +MANAGER_POSITION_ID="00000000-0000-7000-8000-000000000032" +OTHER_POSITION_ID="00000000-0000-7000-8000-000000000033" +SUBORDINATE_POSITION_VERSION_ID="00000000-0000-7000-8000-000000000034" +MANAGER_POSITION_VERSION_ID="00000000-0000-7000-8000-000000000035" +OTHER_POSITION_VERSION_ID="00000000-0000-7000-8000-000000000036" +RELATIONSHIP_ID="00000000-0000-7000-8000-000000000041" +RELATIONSHIP_VERSION_ID="00000000-0000-7000-8000-000000000042" +REVERSE_RELATIONSHIP_ID="00000000-0000-7000-8000-000000000043" +SELF_RELATIONSHIP_ID="00000000-0000-7000-8000-000000000044" +AUDIT_ID="00000000-0000-4000-8000-000000000051" +OUTBOX_ID="00000000-0000-4000-8000-000000000052" +WRONG_REASON_AUDIT_ID="00000000-0000-4000-8000-000000000053" +WRONG_REASON_OUTBOX_ID="00000000-0000-4000-8000-000000000054" +REVIEWER="actor:00000000-0000-4000-8000-000000000061" +APPLIED_BY="actor:00000000-0000-4000-8000-000000000062" +REVIEW_DIGEST="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +with_tenant() { + local tenant="$1" + shift + PGOPTIONS="-c orgmetra.tenant_record_id=${tenant}" command psql "$@" +} + +expect_failure() { + local label="$1" + local needle="$2" + local sql="$3" + local output status + set +e + output="$({ with_tenant "${TENANT_ID}" "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c "${sql}"; } 2>&1)" + status=$? + set -e + if [[ ${status} -eq 0 || "${output}" != *"${needle}"* ]]; then + echo "${label}: ${output}" >&2 + exit 1 + fi +} + +with_tenant "${TENANT_ID}" "${DATABASE_URL}" -v ON_ERROR_STOP=1 <&2 + exit 1 +fi + +expect_failure \ + "position-reporting anchor accepted caller-backdated system time" \ + "transaction timestamp" \ + "INSERT INTO position_reporting_relationship_record ( + tenant_record_id, position_reporting_relationship_record_id, + subordinate_position_record_id, relationship_type_code, recorded_from + ) VALUES ( + '${TENANT_ID}', '00000000-0000-7000-8000-000000000071', + '${SUBORDINATE_POSITION_ID}', 'solid_line', TIMESTAMPTZ '2000-01-01 00:00:00+00' + );" + +expect_failure \ + "position-reporting relationship accepted self-reporting" \ + "cannot report to itself" \ + "INSERT INTO position_reporting_relationship_version (${version_columns}) VALUES ( + '${TENANT_ID}', '00000000-0000-7000-8000-000000000072', + '${SELF_RELATIONSHIP_ID}', '${OTHER_POSITION_ID}', + '${REVIEW_DIGEST}', '${canonical_digest}', '${REVIEWER}', '${APPLIED_BY}', + TIMESTAMPTZ '2026-08-24 01:55:00+00', DATE '2026-08-24', '${AUDIT_ID}' + );" + +expect_failure \ + "position-reporting relationship accepted a management cycle" \ + "cycle" \ + "INSERT INTO position_reporting_relationship_version (${version_columns}) VALUES ( + '${TENANT_ID}', '00000000-0000-7000-8000-000000000073', + '${REVERSE_RELATIONSHIP_ID}', '${SUBORDINATE_POSITION_ID}', + '${REVIEW_DIGEST}', '${canonical_digest}', '${REVIEWER}', '${APPLIED_BY}', + TIMESTAMPTZ '2026-08-24 01:55:00+00', DATE '2026-08-24', '${AUDIT_ID}' + );" + +wrong_reason_event="$(python3 - <&2 + exit 1 +fi + +echo "position-reporting persistence contract passed" diff --git a/tests/test_position_reporting_review_binding_postgres.sh b/tests/test_position_reporting_review_binding_postgres.sh new file mode 100644 index 000000000..b47c5ed3b --- /dev/null +++ b/tests/test_position_reporting_review_binding_postgres.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" +TENANT_ID="10000000-0000-7000-8000-000000000001" +RELATIONSHIP_VERSION_ID="00000000-0000-7000-8000-000000000042" + +binding="$(PGOPTIONS="-c orgmetra.tenant_record_id=${TENANT_ID}" \ + psql "${DATABASE_URL}" -Atqc " +SELECT + version.review_evidence_digest_sha256 || '|' || + (audit.canonical_event_json::jsonb ->> 'orgmetraevidence') || '|' || + version.application_evidence_digest_sha256 || '|' || + audit.event_envelope_digest +FROM position_reporting_relationship_version AS version +JOIN audit_event_record AS audit + ON audit.tenant_record_id = version.tenant_record_id + AND audit.audit_event_record_id = version.audit_event_record_id +WHERE version.tenant_record_id = '${TENANT_ID}'::uuid + AND version.position_reporting_relationship_version_id = '${RELATIONSHIP_VERSION_ID}'::uuid; +")" + +IFS='|' read -r review_digest audit_evidence application_digest audit_digest <<<"${binding}" +if [[ -z "${review_digest}" || "${review_digest}" != "${audit_evidence}" ]]; then + echo "position-reporting review evidence is not bound into immutable application audit evidence: ${binding}" >&2 + exit 1 +fi +if [[ -z "${application_digest}" || "${application_digest}" != "${audit_digest}" ]]; then + echo "position-reporting application evidence digest is not the exact immutable audit envelope digest: ${binding}" >&2 + exit 1 +fi + +echo "position-reporting immutable review/application audit binding passed" diff --git a/tests/validate_repository.py b/tests/validate_repository.py index fe0a329ff..7fbf440ba 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -71,6 +71,7 @@ "database/migrations/0011_criterion_observation_scope.sql", "database/migrations/0012_people_mutation_idempotency.sql", "database/migrations/0013_job_analysis_snapshot.sql", + "database/migrations/0020_position_reporting_relationship.sql", "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", "packages/hris-kernel/tests/test_audit_outbox.py", "schemas/openapi.yaml", @@ -92,6 +93,9 @@ "tests/test_criterion_observation_scope_postgres.sh", "tests/test_people_mutation_idempotency_postgres.sh", "tests/test_job_analysis_snapshot_postgres.sh", + "tests/test_position_reporting_persistence_postgres.sh", + "tests/test_position_reporting_review_binding_postgres.sh", + "tests/test_position_reporting_concurrency_postgres.sh", "tests/validate_repository.py", ]