diff --git a/.github/workflows/candidate-withdrawal-quality.yml b/.github/workflows/candidate-withdrawal-quality.yml new file mode 100644 index 000000000..ff43ddb51 --- /dev/null +++ b/.github/workflows/candidate-withdrawal-quality.yml @@ -0,0 +1,107 @@ +name: Candidate Withdrawal Quality + +on: + pull_request: + branches: + - develop + - feat/normalized-candidate-application + paths: + - "database/migrations/**" + - "tests/test_candidate_withdrawal_postgres.sh" + - "tests/test_candidate_withdrawal_security_postgres.sh" + - "tests/test_candidate_withdrawal_migration_upgrade_postgres.sh" + - ".github/workflows/candidate-withdrawal-quality.yml" + - "docs/adr/0027-governed-candidate-withdrawal.md" + - "docs/traceability/candidate-withdrawal.md" + - "docs/doctoring/candidate-withdrawal-references.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: candidate-withdrawal-quality-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + postgres_contract: + name: Candidate withdrawal PostgreSQL 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: Prove test syntax + run: >- + bash -n + tests/test_candidate_withdrawal_postgres.sh + tests/test_candidate_withdrawal_security_postgres.sh + tests/test_candidate_withdrawal_migration_upgrade_postgres.sh + - name: Prove governed candidate withdrawal persistence + run: bash tests/test_candidate_withdrawal_postgres.sh + - name: Prove anti-forgery and tenant isolation + run: bash tests/test_candidate_withdrawal_security_postgres.sh + - name: Prove validation is read-only + run: | + git diff --exit-code + test -z "$(git status --porcelain)" + + postgres_migration_upgrade_contract: + name: Candidate withdrawal migration upgrade 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: Prove migration upgrade path + run: bash tests/test_candidate_withdrawal_migration_upgrade_postgres.sh + - name: Prove validation is read-only + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 78cb293ed..fb740b2db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ 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 candidate-withdrawal persistence now binds identity-resolution reference/digest, withdrawal evidence reference/digest, and evidence version into the candidate-specific immutable audit envelope as well as the append-only withdrawal fact; generic audit envelopes retain their exact existing data contract. +- Active-PR candidate-withdrawal persistence now delivers its audit-envelope extension through forward-only migration `0016_candidate_withdrawal_audit_envelope.sql`, preserving already-applied migration `0008` while proving the legacy-to-upgraded validator path in a disposable PostgreSQL contract. +- Active-PR candidate-withdrawal persistence now assigns `recorded_at` from PostgreSQL transaction time at the insert trigger, rejecting caller-supplied backdated or future system-recorded evidence while preserving candidate-declared `withdrawn_at` chronology. - 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/0014_candidate_application_core.sql b/database/migrations/0014_candidate_application_core.sql index 5a1191760..52676edc8 100644 --- a/database/migrations/0014_candidate_application_core.sql +++ b/database/migrations/0014_candidate_application_core.sql @@ -19,6 +19,10 @@ ALTER TABLE public.position_record ADD CONSTRAINT position_record_tenant_position_job_unique UNIQUE (tenant_record_id, position_record_id, job_profile_id); +-- Table creation stays search-path-qualified rather than schema-qualified because +-- the repository foundation validator applies the same descriptive two-word +-- snake_case rule to an explicit schema token. SET LOCAL search_path above keeps +-- these objects owned by public without weakening that repository-wide check. -- The durable application identity is immutable. The candidate/requisition pair -- identifies the application; correctable Job/Position scope belongs to the -- bitemporal version relation below so stage history never has to change anchor. diff --git a/database/migrations/0015_candidate_withdrawal_governance.sql b/database/migrations/0015_candidate_withdrawal_governance.sql new file mode 100644 index 000000000..7943a18d6 --- /dev/null +++ b/database/migrations/0015_candidate_withdrawal_governance.sql @@ -0,0 +1,195 @@ +-- Govern candidate-initiated application withdrawal without reusing raw workflow +-- stage codes for a terminal action. The application layer remains responsible +-- for authenticating the candidate; this persistence boundary requires the +-- resulting identity-resolution evidence, candidate actor, withdrawal evidence, +-- and exact immutable audit/outbox correlation to agree before accepting a row. + +BEGIN; + +SET LOCAL search_path = public, pg_catalog; + +CREATE TABLE candidate_withdrawal_record ( + tenant_record_id uuid NOT NULL REFERENCES public.tenant_record(tenant_record_id), + candidate_withdrawal_record_id uuid PRIMARY KEY, + candidate_application_record_id uuid NOT NULL, + initiating_actor_reference text NOT NULL, + identity_resolution_reference text NOT NULL, + identity_resolution_digest text NOT NULL, + withdrawal_evidence_reference text NOT NULL, + withdrawal_evidence_digest text NOT NULL, + evidence_version integer NOT NULL, + withdrawn_at timestamptz NOT NULL, + audit_event_record_id uuid NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT pg_catalog.transaction_timestamp(), + CONSTRAINT candidate_withdrawal_record_id_operational_check + CHECK ( + candidate_withdrawal_record_id <> '00000000-0000-0000-0000-000000000000'::uuid + AND candidate_withdrawal_record_id <> 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid + ), + CONSTRAINT candidate_withdrawal_application_tenant_fk + FOREIGN KEY (tenant_record_id, candidate_application_record_id) + REFERENCES public.candidate_application_record( + tenant_record_id, candidate_application_record_id + ), + CONSTRAINT candidate_withdrawal_audit_tenant_fk + FOREIGN KEY (tenant_record_id, audit_event_record_id) + REFERENCES public.audit_event_record(tenant_record_id, audit_event_record_id), + CONSTRAINT candidate_withdrawal_actor_reference_check + CHECK ( + initiating_actor_reference ~ + '^candidate:[A-Za-z0-9][A-Za-z0-9._~-]*$' + ), + CONSTRAINT candidate_withdrawal_identity_reference_check + CHECK ( + identity_resolution_reference ~ + '^identity_resolution:[A-Za-z0-9][A-Za-z0-9._~-]*$' + ), + CONSTRAINT candidate_withdrawal_identity_digest_check + CHECK (identity_resolution_digest ~ '^[0-9a-f]{64}$'), + CONSTRAINT candidate_withdrawal_evidence_reference_check + CHECK ( + withdrawal_evidence_reference ~ + '^candidate_withdrawal_evidence:[A-Za-z0-9][A-Za-z0-9._~-]*$' + ), + CONSTRAINT candidate_withdrawal_evidence_digest_check + CHECK (withdrawal_evidence_digest ~ '^[0-9a-f]{64}$'), + CONSTRAINT candidate_withdrawal_evidence_version_check + CHECK (evidence_version >= 1 AND evidence_version <= 1000000), + CONSTRAINT candidate_withdrawal_recorded_order_check + CHECK (withdrawn_at <= recorded_at), + CONSTRAINT candidate_withdrawal_tenant_identity_unique + UNIQUE (tenant_record_id, candidate_withdrawal_record_id), + CONSTRAINT candidate_withdrawal_application_unique + UNIQUE (tenant_record_id, candidate_application_record_id), + CONSTRAINT candidate_withdrawal_audit_identity_unique + UNIQUE (tenant_record_id, audit_event_record_id) +); + +CREATE FUNCTION public.validate_candidate_withdrawal_evidence() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +DECLARE + application_submitted_at timestamptz; + audit_event_envelope jsonb; + audit_event_time timestamptz; +BEGIN + -- recorded_at is database-owned evidence, not a caller-provided timestamp. + NEW.recorded_at := pg_catalog.transaction_timestamp(); + + SELECT application_record.submitted_at + INTO application_submitted_at + FROM public.candidate_application_record AS application_record + WHERE application_record.tenant_record_id = NEW.tenant_record_id + AND application_record.candidate_application_record_id = + NEW.candidate_application_record_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'candidate withdrawal requires a tenant-local application' + USING ERRCODE = '23503'; + END IF; + + IF NEW.withdrawn_at < application_submitted_at THEN + RAISE EXCEPTION 'candidate withdrawal cannot predate application submission' + USING ERRCODE = '23514'; + END IF; + + SELECT audit_record.canonical_event_json::jsonb + INTO audit_event_envelope + FROM public.audit_event_record AS audit_record + WHERE audit_record.tenant_record_id = NEW.tenant_record_id + AND audit_record.audit_event_record_id = NEW.audit_event_record_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'candidate withdrawal requires a tenant-local immutable audit event' + USING ERRCODE = '23503'; + END IF; + + audit_event_time := (audit_event_envelope ->> 'time')::timestamptz; + + IF audit_event_envelope ->> 'source' <> 'urn:orgmetra:talent_acquisition' + OR audit_event_envelope ->> 'type' <> 'orgmetra.candidate.application_withdrawn' + OR audit_event_envelope ->> 'subject' + <> 'candidate_withdrawal_record:' || NEW.candidate_withdrawal_record_id::text + OR audit_event_envelope ->> 'orgmetraactor' <> NEW.initiating_actor_reference + OR audit_event_envelope ->> 'orgmetrapurpose' <> 'candidate_withdrawal' + OR audit_event_envelope ->> 'orgmetrareason' <> 'candidate_requested' + OR audit_event_envelope ->> 'orgmetraevidence' <> NEW.withdrawal_evidence_reference + OR audit_event_envelope #>> '{data,identity_resolution_reference}' + <> NEW.identity_resolution_reference + OR audit_event_envelope #>> '{data,identity_resolution_digest}' + <> NEW.identity_resolution_digest + OR audit_event_envelope #>> '{data,withdrawal_evidence_digest}' + <> NEW.withdrawal_evidence_digest + OR audit_event_envelope #>> '{data,evidence_version}' + <> NEW.evidence_version::text + OR audit_event_envelope ? 'orgmetraconfirmation' + OR (audit_event_envelope #>> '{data,high_impact}')::boolean IS NOT FALSE + OR audit_event_envelope #>> '{data,result_code}' <> 'application_withdrawn' + OR audit_event_time <> NEW.withdrawn_at + OR audit_event_time > NEW.recorded_at THEN + RAISE EXCEPTION 'candidate withdrawal audit envelope does not bind exact candidate provenance' + USING ERRCODE = '23514'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.outbox_delivery_record AS delivery_record + WHERE delivery_record.tenant_record_id = NEW.tenant_record_id + AND delivery_record.audit_event_record_id = NEW.audit_event_record_id + ) THEN + RAISE EXCEPTION 'candidate withdrawal audit event requires transactional outbox delivery evidence' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER candidate_withdrawal_governance_guard +BEFORE INSERT ON candidate_withdrawal_record +FOR EACH ROW +EXECUTE FUNCTION public.validate_candidate_withdrawal_evidence(); + +CREATE FUNCTION public.reject_candidate_withdrawal_mutation() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + RAISE EXCEPTION 'candidate withdrawal evidence is append-only' + USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER candidate_withdrawal_append_only_guard +BEFORE UPDATE OR DELETE ON candidate_withdrawal_record +FOR EACH ROW +EXECUTE FUNCTION public.reject_candidate_withdrawal_mutation(); + +CREATE FUNCTION public.reject_candidate_withdrawal_truncate() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog, public, pg_temp +AS $$ +BEGIN + RAISE EXCEPTION 'candidate withdrawal evidence cannot be truncated' + USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER candidate_withdrawal_truncate_guard +BEFORE TRUNCATE ON candidate_withdrawal_record +FOR EACH STATEMENT +EXECUTE FUNCTION public.reject_candidate_withdrawal_truncate(); + +REVOKE TRUNCATE ON candidate_withdrawal_record FROM PUBLIC; + +ALTER TABLE candidate_withdrawal_record ENABLE ROW LEVEL SECURITY; +ALTER TABLE candidate_withdrawal_record FORCE ROW LEVEL SECURITY; +CREATE POLICY candidate_withdrawal_scope_policy ON public.candidate_withdrawal_record +USING (tenant_record_id = public.current_tenant_record_id()) +WITH CHECK (tenant_record_id = public.current_tenant_record_id()); + +COMMIT; diff --git a/database/migrations/0016_candidate_withdrawal_audit_envelope.sql b/database/migrations/0016_candidate_withdrawal_audit_envelope.sql new file mode 100644 index 000000000..fc2cab2c7 --- /dev/null +++ b/database/migrations/0016_candidate_withdrawal_audit_envelope.sql @@ -0,0 +1,243 @@ +-- Extend the already-deployed audit envelope validator through a forward-only +-- migration. Do not edit 0008: existing databases must receive this change. + +BEGIN; + +SET LOCAL search_path = public, pg_catalog; + +CREATE OR REPLACE FUNCTION public.validate_audit_event_envelope( + p_canonical_event_json text, + p_audit_event_record_id uuid, + p_tenant_record_id uuid, + p_event_envelope_digest text +) +RETURNS boolean +LANGUAGE plpgsql +IMMUTABLE +STRICT +SET search_path = pg_catalog, public, pg_temp +AS $$ +DECLARE + event_envelope jsonb; + event_data jsonb; + event_keys text[]; + data_keys text[]; + event_high_impact boolean; + event_time_text text; + event_year integer; + event_month integer; + event_day integer; + event_hour integer; + event_minute integer; + event_second integer; + expected_keys_without_confirmation constant text[] := ARRAY[ + 'data', + 'datacontenttype', + 'id', + 'orgmetraactor', + 'orgmetraevidence', + 'orgmetrapurpose', + 'orgmetrareason', + 'orgmetratenant', + 'source', + 'specversion', + 'subject', + 'time', + 'type' + ]; + expected_keys_with_confirmation constant text[] := ARRAY[ + 'data', + 'datacontenttype', + 'id', + 'orgmetraactor', + 'orgmetraconfirmation', + 'orgmetraevidence', + 'orgmetrapurpose', + 'orgmetrareason', + 'orgmetratenant', + 'source', + 'specversion', + 'subject', + 'time', + 'type' + ]; + candidate_withdrawal_data_keys constant text[] := ARRAY[ + 'evidence_version', + 'high_impact', + 'identity_resolution_digest', + 'identity_resolution_reference', + 'result_code', + 'withdrawal_evidence_digest' + ]; +BEGIN + IF public.is_operational_uuid(p_audit_event_record_id) IS NOT TRUE + OR public.is_operational_uuid(p_tenant_record_id) IS NOT TRUE THEN + RETURN false; + END IF; + + BEGIN + event_envelope := p_canonical_event_json::jsonb; + EXCEPTION + WHEN others THEN + RETURN false; + END; + + IF pg_catalog.jsonb_typeof(event_envelope) <> 'object' THEN + RETURN false; + END IF; + + SELECT pg_catalog.array_agg(event_key ORDER BY event_key COLLATE "C") + INTO event_keys + FROM pg_catalog.jsonb_object_keys(event_envelope) AS event_key_set(event_key); + + IF event_keys IS NULL + OR ( + event_keys IS DISTINCT FROM expected_keys_without_confirmation + AND event_keys IS DISTINCT FROM expected_keys_with_confirmation + ) THEN + RETURN false; + END IF; + + event_data := event_envelope -> 'data'; + IF pg_catalog.jsonb_typeof(event_data) <> 'object' THEN + RETURN false; + END IF; + + SELECT pg_catalog.array_agg(data_key ORDER BY data_key COLLATE "C") + INTO data_keys + FROM pg_catalog.jsonb_object_keys(event_data) AS data_key_set(data_key); + IF event_envelope ->> 'source' = 'urn:orgmetra:talent_acquisition' + AND event_envelope ->> 'type' = 'orgmetra.candidate.application_withdrawn' THEN + IF data_keys IS DISTINCT FROM candidate_withdrawal_data_keys THEN + RETURN false; + END IF; + ELSIF data_keys IS DISTINCT FROM ARRAY['high_impact', 'result_code']::text[] THEN + RETURN false; + END IF; + + IF event_envelope ->> 'specversion' <> '1.0' + OR event_envelope ->> 'datacontenttype' <> 'application/json' + OR event_envelope ->> 'id' <> p_audit_event_record_id::text + OR event_envelope ->> 'orgmetratenant' <> p_tenant_record_id::text THEN + RETURN false; + END IF; + + IF pg_catalog.jsonb_typeof(event_envelope -> 'id') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'source') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'type') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'subject') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'time') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'orgmetraactor') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'orgmetrapurpose') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'orgmetrareason') <> 'string' + OR pg_catalog.jsonb_typeof(event_envelope -> 'orgmetraevidence') <> 'string' + OR pg_catalog.jsonb_typeof(event_data -> 'result_code') <> 'string' + OR pg_catalog.jsonb_typeof(event_data -> 'high_impact') <> 'boolean' THEN + RETURN false; + END IF; + + IF (event_envelope ->> 'source') COLLATE "C" + !~ '^urn:orgmetra:[a-z][a-z0-9]*(?:_[a-z0-9]+)+$' + OR (event_envelope ->> 'type') COLLATE "C" + !~ '^orgmetra(?:\.[a-z][a-z0-9_]*){2,}$' + OR (event_envelope ->> 'subject') COLLATE "C" + !~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' + OR (event_envelope ->> 'orgmetraactor') COLLATE "C" + !~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' + OR (event_envelope ->> 'orgmetrapurpose') COLLATE "C" + !~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + OR (event_envelope ->> 'orgmetrareason') COLLATE "C" + !~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + OR (event_envelope ->> 'orgmetraevidence') COLLATE "C" + !~ '^[A-Za-z0-9][A-Za-z0-9._:-]*$' + OR (event_data ->> 'result_code') COLLATE "C" + !~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' THEN + RETURN false; + END IF; + + IF event_envelope ->> 'source' = 'urn:orgmetra:talent_acquisition' + AND event_envelope ->> 'type' = 'orgmetra.candidate.application_withdrawn' + AND ( + pg_catalog.jsonb_typeof(event_data -> 'evidence_version') + IS DISTINCT FROM 'number' + OR (event_data ->> 'evidence_version') IS NULL + OR ( + CASE + WHEN (event_data ->> 'evidence_version') COLLATE "C" + ~ '^[1-9][0-9]{0,6}$' + THEN (event_data ->> 'evidence_version')::integer + BETWEEN 1 AND 1000000 + ELSE false + END + ) IS NOT TRUE + OR pg_catalog.jsonb_typeof(event_data -> 'identity_resolution_reference') + IS DISTINCT FROM 'string' + OR (event_data ->> 'identity_resolution_reference') IS NULL + OR (event_data ->> 'identity_resolution_reference') COLLATE "C" + !~ '^identity_resolution:[A-Za-z0-9][A-Za-z0-9._~-]*$' + OR pg_catalog.jsonb_typeof(event_data -> 'identity_resolution_digest') + IS DISTINCT FROM 'string' + OR (event_data ->> 'identity_resolution_digest') IS NULL + OR (event_data ->> 'identity_resolution_digest') COLLATE "C" + !~ '^[0-9a-f]{64}$' + OR pg_catalog.jsonb_typeof(event_data -> 'withdrawal_evidence_digest') + IS DISTINCT FROM 'string' + OR (event_data ->> 'withdrawal_evidence_digest') IS NULL + OR (event_data ->> 'withdrawal_evidence_digest') COLLATE "C" + !~ '^[0-9a-f]{64}$' + ) THEN + RETURN false; + END IF; + + event_time_text := event_envelope ->> 'time'; + IF event_time_text COLLATE "C" + !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$' THEN + RETURN false; + END IF; + + BEGIN + event_year := pg_catalog.substr(event_time_text, 1, 4)::integer; + event_month := pg_catalog.substr(event_time_text, 6, 2)::integer; + event_day := pg_catalog.substr(event_time_text, 9, 2)::integer; + event_hour := pg_catalog.substr(event_time_text, 12, 2)::integer; + event_minute := pg_catalog.substr(event_time_text, 15, 2)::integer; + event_second := pg_catalog.substr(event_time_text, 18, 2)::integer; + PERFORM pg_catalog.make_date(event_year, event_month, event_day); + EXCEPTION + WHEN others THEN + RETURN false; + END; + + IF event_hour > 23 OR event_minute > 59 OR event_second > 59 THEN + RETURN false; + END IF; + + event_high_impact := (event_data ->> 'high_impact')::boolean; + IF event_high_impact THEN + IF NOT (event_envelope ? 'orgmetraconfirmation') + OR pg_catalog.jsonb_typeof(event_envelope -> 'orgmetraconfirmation') <> 'string' + OR (event_envelope ->> 'orgmetraconfirmation') COLLATE "C" + !~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' THEN + RETURN false; + END IF; + ELSIF event_envelope ? 'orgmetraconfirmation' THEN + IF pg_catalog.jsonb_typeof(event_envelope -> 'orgmetraconfirmation') <> 'string' + OR (event_envelope ->> 'orgmetraconfirmation') COLLATE "C" + !~ '^[a-z][a-z0-9_]*:[A-Za-z0-9][A-Za-z0-9._~-]*$' THEN + RETURN false; + END IF; + END IF; + + IF p_event_envelope_digest COLLATE "C" !~ '^[0-9a-f]{64}$' + OR pg_catalog.encode( + public.digest(pg_catalog.convert_to(p_canonical_event_json, 'UTF8'), 'sha256'), + 'hex' + ) <> p_event_envelope_digest THEN + RETURN false; + END IF; + + RETURN true; +END; +$$; + +COMMIT; diff --git a/docs/adr/0025-governed-candidate-evidence-intake.md b/docs/adr/0025-governed-candidate-evidence-intake.md index ab66b3088..e6eb982a8 100644 --- a/docs/adr/0025-governed-candidate-evidence-intake.md +++ b/docs/adr/0025-governed-candidate-evidence-intake.md @@ -1,6 +1,7 @@ # ADR 0025: Govern candidate evidence intake as reference-only evidence -- **Status:** Proposed — active PR only +- Status: Proposed +- Provenance: active PR only - **Date:** 2026-08-19 ## Context diff --git a/docs/adr/0026-normalized-candidate-application.md b/docs/adr/0026-normalized-candidate-application.md index 586daf000..ed907d9bd 100644 --- a/docs/adr/0026-normalized-candidate-application.md +++ b/docs/adr/0026-normalized-candidate-application.md @@ -1,6 +1,7 @@ # ADR 0026: Normalize candidate applications away from candidate identity -- **Status:** Proposed on active PR; not protected-main truth until merged +- Status: Proposed +- Provenance: active PR; not protected-main truth until merged - **Date:** 2026-08-21 - **Decision owner:** Orgmetra diff --git a/docs/adr/0027-governed-candidate-withdrawal.md b/docs/adr/0027-governed-candidate-withdrawal.md new file mode 100644 index 000000000..ea09390b9 --- /dev/null +++ b/docs/adr/0027-governed-candidate-withdrawal.md @@ -0,0 +1,86 @@ +# ADR 0027: Govern candidate-initiated application withdrawal as immutable evidence + +- Status: Proposed +- Provenance: active stacked PR; not protected-main truth until merged +- **Date:** 2026-08-21 +- **Decision owner:** Orgmetra +- **Dependency:** ADR 0026 / PR #66 normalized candidate application + +## Context + +ADR 0026 intentionally keeps `withdrawn` out of raw application-stage persistence. A terminal value named `withdrawn` is not self-authenticating: if the persistence layer accepts the word from any staff-facing workflow, a staff caller can encode a shadow rejection that appears candidate-driven. A defensible HRIS therefore needs a distinct evidence boundary that binds one exact application, the initiating candidate identity assertion, versioned withdrawal evidence, time, tenant, and immutable audit/outbox correlation. + +NIST SP 800-63-4 and SP 800-63B-4 provide current primary guidance for digital identity and authentication. Orgmetra does not claim NIST conformance or assign an assurance level in this slice; the relevant design principle is narrower: a candidate-shaped identifier is not itself proof that the authenticated claimant is that candidate. Authentication/identity-resolution evidence must be produced by the authorized identity adapter and carried into the governed operation as evidence. + +The existing Orgmetra audit/outbox boundary uses CloudEvents-compatible envelopes. The CloudEvents project identifies v1.0.2 as the latest released core specification. PostgreSQL 16 row-level security and trigger semantics provide the local persistence mechanisms used here for tenant isolation and immutable validation. These sources inform the boundary but do not substitute for Orgmetra's product-specific controls. + +## Decision + +Migration `0015_candidate_withdrawal_governance.sql` introduces `candidate_withdrawal_record`, a tenant-owned append-only terminal fact separate from `candidate_application_stage_record`. + +A withdrawal row binds: + +1. one tenant-local `candidate_application_record`; +2. one `candidate:` initiating actor reference; +3. an `identity_resolution:` reference plus SHA-256 digest produced by the authorized identity/authentication boundary; +4. a `candidate_withdrawal_evidence:` reference plus SHA-256 digest and bounded positive evidence version; +5. the candidate-declared `withdrawn_at` instant; +6. one immutable tenant-local `audit_event_record`; and +7. the system `recorded_at` instant. + +The insert guard always assigns `recorded_at` from PostgreSQL transaction time; +callers cannot backdate or future-date the system-recorded evidence by supplying +the column explicitly. + +Exactly one withdrawal may exist for one application. The row is append-only: UPDATE, DELETE, and TRUNCATE are rejected. The table forces row-level security using Orgmetra's existing tenant-context function. + +Before insert, `validate_candidate_withdrawal_evidence()` re-resolves the tenant-local application and immutable audit event. It rejects withdrawal before application submission and requires the audit envelope to bind the exact withdrawal record, candidate actor, identity-resolution reference/digest, withdrawal evidence reference/digest, evidence version, purpose `candidate_withdrawal`, reason `candidate_requested`, event time, and non-high-impact `application_withdrawn` result. A matching transactional outbox delivery record must already exist. + +The generic audit API may represent many actor namespaces. Therefore `candidate_withdrawal_record` independently restricts its initiating actor to the `candidate:` namespace. A valid generic audit envelope with a `staff:` actor cannot become a withdrawal row. + +## Identity boundary + +This migration does **not** authenticate candidates by parsing the `candidate:` string and does not directly query Keyverse or any foreign application table. The `identity_resolution_reference` and digest are evidence inputs from Orgmetra's authorized identity adapter boundary. The adapter remains responsible for resolving the authenticated principal to the application candidate under the published identity contract before writing the withdrawal transaction. + +The database then protects what it can authoritatively prove locally: tenant/application identity, candidate actor namespace, exact audit correlation, evidence shapes and versions, chronology, one-withdrawal cardinality, append-only history, outbox presence, and tenant RLS. This separation preserves standalone operation and the dedicated-writer boundary without pretending that database syntax alone establishes digital identity. + +## Relationship to employment decisions + +Candidate withdrawal is modeled as the candidate terminating their own application process, so the audit event is explicitly `high_impact=false` and carries no fabricated employer confirmation token. Employer-driven adverse or favorable outcomes remain governed by `selection_decision` and its human-confirmed evidence boundary. The raw application-stage vocabulary remains non-terminal; this migration does not re-add `withdrawn`, `rejected`, `closed`, or `hired` as ordinary stages. + +If product requirements later allow an authorized representative to withdraw on a candidate's behalf, that is a separate delegation/representation contract with its own actor and authority evidence. It must not be implemented by broadening the current `candidate:` check to `staff:`. + +## Consequences + +- Candidate withdrawal is represented without overwriting candidate identity or smuggling a terminal outcome into an ordinary workflow stage. +- Staff callers cannot record a withdrawal merely by choosing a `withdrawn` status or by creating a generic audit envelope with a staff actor. +- Audit/event time and immutable evidence remain correlated with the exact withdrawal row. +- System-recorded time is assigned by PostgreSQL at insert and cannot be supplied by the caller. +- One application cannot accumulate contradictory multiple withdrawal facts. +- Missing tenant context fails closed under forced RLS; tenant Alpha and Beta withdrawals are mutually invisible to a `NOBYPASSRLS` reader. +- The boundary remains dependent on the application layer's authorized identity adapter for actual claimant authentication and identity resolution. This ADR does not claim otherwise. + +## Rejected alternatives + +### Re-add `withdrawn` to `candidate_application_stage_record` + +Rejected because the stage row has no initiating-actor or identity-resolution provenance. That would recreate the shadow-rejection defect that ADR 0026 closed. + +### Treat candidate withdrawal as `selection_decision` + +Rejected because a candidate terminating their own application is not an employer selection decision. Reusing the high-impact employer-decision model would misstate authority and could manufacture an inappropriate human-confirmation requirement. + +### Directly query Keyverse application tables from the database trigger + +Rejected because it violates the dedicated-writer/service boundary and creates direct cross-service database coupling. Orgmetra consumes identity proof through its authorized adapter/API contract instead. + +### Trust a `candidate:` string without evidence + +Rejected because namespace syntax is correlation, not authentication. The governed row therefore requires explicit identity-resolution reference/digest evidence and exact audit binding in addition to the actor namespace. + +## Evidence + +- `tests/test_candidate_withdrawal_postgres.sh` proves the governed happy path, preserves fail-closed rejection of raw `withdrawn` stage persistence, and proves append-only history. +- `tests/test_candidate_withdrawal_security_postgres.sh` isolates one-withdrawal cardinality, valid-audit/staff-actor rejection, audit/evidence anti-forgery, positive Alpha/Beta RLS visibility, missing-context fail-closed behavior, and TRUNCATE rejection. +- `.github/workflows/candidate-withdrawal-quality.yml` checks out the exact candidate SHA, uses pinned PostgreSQL 16.14, runs both focused contracts, and requires a clean checkout afterward. +- Primary-source review and APA 7 references are recorded in `docs/doctoring/candidate-withdrawal-references.md`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5df6c3b35..625a77684 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,4 +16,7 @@ | [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 | -| [0026](0026-normalized-candidate-application.md) | Normalize the candidate application lifecycle | Accepted on active implementation branch | +| [0017](0017-governed-offer-approval.md) | Governed offer approval evidence | Proposed | +| [0025](0025-governed-candidate-evidence-intake.md) | Governed candidate evidence intake | Proposed | +| [0026](0026-normalized-candidate-application.md) | Normalize the candidate application lifecycle | Proposed | +| [0027](0027-governed-candidate-withdrawal.md) | Govern candidate-initiated application withdrawal as immutable evidence | Proposed | diff --git a/docs/doctoring/candidate-withdrawal-references.md b/docs/doctoring/candidate-withdrawal-references.md new file mode 100644 index 000000000..1d1b2e9ae --- /dev/null +++ b/docs/doctoring/candidate-withdrawal-references.md @@ -0,0 +1,39 @@ +# Candidate withdrawal primary-source references + +## Scope note + +These sources inform the design of Orgmetra's governed candidate-withdrawal boundary. They do not establish certification or conformance, and none of them is interpreted as prescribing Orgmetra's exact physical schema. The implementation remains an Orgmetra-owned product decision. + +## Design findings + +### Digital identity and authentication + +NIST SP 800-63-4 is the current final Digital Identity Guidelines revision (July 2025) and covers identity proofing, authentication, federation, and related assertions. SP 800-63B-4 is the companion final publication focused on authentication and authenticator management. For this slice, the material design consequence is that an identifier such as `candidate:` must not be treated as proof that the current claimant is that candidate. Orgmetra therefore keeps identity/authentication verification at the authorized adapter boundary and persists a reference/digest to the resulting identity-resolution evidence rather than pretending that actor-string syntax authenticates a person. + +This PR does not assign an Identity Assurance Level, Authenticator Assurance Level, or Federation Assurance Level and makes no NIST conformance claim. + +### Event interoperability + +The CloudEvents project lists v1.0.2 as the latest released core CloudEvents specification. Orgmetra's existing audit/outbox persistence already uses a CloudEvents-compatible version-1.0 envelope with product-specific governance extensions. Candidate withdrawal reuses that accepted envelope and binds the exact withdrawal subject, candidate actor, identity-resolution reference/digest, withdrawal evidence reference/digest, evidence version, purpose, reason, time, and result rather than inventing a second event format. + +### PostgreSQL persistence controls + +PostgreSQL 16 `CREATE POLICY` documentation specifies that row-level security policies become effective after RLS is enabled and that `USING` controls visible rows while `WITH CHECK` controls proposed rows. The candidate-withdrawal relation follows the repository's existing FORCE-RLS tenant-context pattern and tests it using a `NOBYPASSRLS` role. + +PostgreSQL 16 `CREATE TRIGGER` documentation defines BEFORE row triggers as executing before the attempted row operation and supports TRUNCATE triggers. Orgmetra uses a BEFORE INSERT governance trigger to re-resolve local application/audit evidence and separate UPDATE/DELETE/TRUNCATE rejection triggers to preserve append-only withdrawal history. + +## APA 7 references + +Cloud Native Computing Foundation. (2022). *CloudEvents specification* (Version 1.0.2). GitHub. https://github.com/cloudevents/spec/tree/v1.0.2 + +PostgreSQL Global Development Group. (2026). *CREATE POLICY*. In *PostgreSQL 16 documentation*. https://www.postgresql.org/docs/16/sql-createpolicy.html + +PostgreSQL Global Development Group. (2026). *CREATE TRIGGER*. In *PostgreSQL 16 documentation*. https://www.postgresql.org/docs/16/sql-createtrigger.html + +Temoshok, D., Choong, Y.-Y., Galluzzo, R., LaSalle, M., Regenscheid, A., Proud-Madruga, D., Gupta, S., & Lefkovitz, N. (2025). *Digital identity guidelines* (NIST Special Publication 800-63-4). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63-4 + +Temoshok, D., Fenton, J., Choong, Y.-Y., Lefkovitz, N., Regenscheid, A., Galluzzo, R., & Richer, J. (2025). *Digital identity guidelines: Authentication and authenticator management* (NIST Special Publication 800-63B-4). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-63B-4 + +## Source verification date + +Primary sources were rechecked on 2026-08-21. NIST identifies SP 800-63-4 and SP 800-63B-4 as final July 2025 publications; the CloudEvents repository identifies v1.0.2 as the latest released core specification; PostgreSQL URLs above are the official version-16 documentation. diff --git a/docs/traceability/candidate-withdrawal.md b/docs/traceability/candidate-withdrawal.md new file mode 100644 index 000000000..5d33207c2 --- /dev/null +++ b/docs/traceability/candidate-withdrawal.md @@ -0,0 +1,32 @@ +# Candidate withdrawal traceability + +## State + +**Active stacked PR only.** This document describes `feat/governed-candidate-withdrawal` stacked on PR #66. It is not protected-`develop` truth until both dependency order and merge governance are satisfied. + +## Requirement-to-evidence map + +| Requirement | Implementation evidence | Executable evidence | +|---|---|---| +| Withdrawal belongs to one exact tenant-local application | composite FK `(tenant_record_id, candidate_application_record_id)` plus insert trigger re-resolution in `0015_candidate_withdrawal_governance.sql` | `test_candidate_withdrawal_postgres.sh`; `test_candidate_withdrawal_security_postgres.sh` | +| Candidate initiation cannot be replaced by a staff workflow action | `initiating_actor_reference` accepts only `candidate:` namespace; generic audit actor must exactly match the row | `test_candidate_withdrawal_security_postgres.sh` creates a fully valid generic staff-actor audit event and proves the withdrawal row rejects it | +| Identity syntax is not treated as authentication | row requires `identity_resolution_reference` and SHA-256 digest; authorized identity adapter remains responsible for claimant→candidate resolution | ADR 0027 documents the adapter boundary; database tests prove the evidence shape is mandatory but do **not** claim external identity authentication | +| Withdrawal evidence is versioned and immutable | withdrawal evidence reference/digest, bounded `evidence_version`, append-only UPDATE/DELETE/TRUNCATE guards | both candidate-withdrawal PostgreSQL tests | +| Audit envelope binds the exact withdrawal | insert trigger requires exact subject, actor, identity-resolution reference/digest, evidence reference/digest, evidence version, purpose, reason, event time and result; transactional outbox row required | `test_candidate_withdrawal_security_postgres.sh` supplies candidate-shaped but mismatched provenance and proves fail-closed rejection | +| Existing databases receive the withdrawal envelope rule | forward-only `0016_candidate_withdrawal_audit_envelope.sql` replaces the already-deployed validator after legacy `0008` has run; `0008` itself remains immutable | `test_candidate_withdrawal_migration_upgrade_postgres.sh` proves legacy rejection followed by upgraded acceptance | +| Candidate withdrawal is not an employer high-impact decision | audit envelope requires `high_impact=false` and has no `orgmetraconfirmation`; employer outcomes remain in `selection_decision` | `test_candidate_withdrawal_postgres.sh`; migration trigger predicates | +| Raw ATS workflow cannot smuggle terminal withdrawal | PR #66 stage vocabulary remains non-terminal; PR #67 does not add `withdrawn` to it | `test_candidate_withdrawal_postgres.sh` attempts raw `withdrawn` stage insertion and requires `candidate_application_stage_code_check` | +| Application chronology is preserved | withdrawal cannot predate application `submitted_at`; audit time must equal `withdrawn_at` and not exceed database-owned `recorded_at`, which the insert trigger assigns from transaction time | migration trigger exercised by happy-path, caller-future-time, and anti-forgery contracts | +| One application has at most one withdrawal fact | unique `(tenant_record_id, candidate_application_record_id)` | security test uses a second otherwise-valid audit event so the duplicate fails specifically at `candidate_withdrawal_application_unique` | +| Tenant isolation fails closed | FORCE RLS with existing `current_tenant_record_id()` policy | security test uses a `NOBYPASSRLS` reader: no context sees zero; Alpha and Beta each see only their own row | +| Validation runs on exact candidate source | pinned checkout plus explicit `git rev-parse HEAD` equality proof | `.github/workflows/candidate-withdrawal-quality.yml` | + +## Explicit non-claims + +The database does not authenticate a human candidate, verify an external authenticator, or query Keyverse directly. `candidate:` is an opaque actor namespace, not proof of identity. The identity-resolution reference/digest is meaningful only when supplied by the authorized Orgmetra identity adapter after resolving the authenticated principal under the published identity contract. This slice deliberately preserves that service boundary. + +The slice also does not implement candidate self-service UI, e-mail/SMS confirmation, delegated representative withdrawal, rescission/reinstatement, or employer closure. Those require separate governed commands and buyer-visible workflow design rather than broader status codes. + +## Standards and research linkage + +NIST SP 800-63-4 and SP 800-63B-4 inform the separation between an identifier and authenticated claimant evidence. CloudEvents v1.0.2 informs the existing event envelope shape. PostgreSQL 16 `CREATE POLICY` and `CREATE TRIGGER` semantics underpin the local tenant-isolation and insert/immutability enforcement mechanisms. APA 7 references and source notes are in `docs/doctoring/candidate-withdrawal-references.md`. diff --git a/manifest.json b/manifest.json index 7846b1d05..ac52cd20a 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1,583 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/normalized-candidate-application","files":[{"path":".github/workflows/candidate-application-quality.yml","sha256":"3386ec450a411a57a4e3c3bfec1558287cd04a229131f39873cc49f3e301c931","bytes":3467,"lines":81},{"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":"9dc8a1d5d6ce86832cef8697e53c8ca779ebe231a0edcaf20a2e20ed8c80be28","bytes":18201,"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/0014_candidate_application_core.sql","sha256":"68cc8bde78ad95d24cbdc027422151333d37d562acba536693f0979505f0ca39","bytes":13568,"lines":303},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"9565f012162a0111ab29c5135207cf01d38af64d3e1def8e1a937293b2e3edf6","bytes":14913,"lines":90},{"path":"docs/ERD.md","sha256":"f979d4de859d40332ef8cfa5182c9ea16b0244583c49250a5d77a588af574246","bytes":8331,"lines":77},{"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/0026-normalized-candidate-application.md","sha256":"c8c2abd1a53941daa1261a31dad5b3739703150c18a560aac022dcfd3d2b080f","bytes":9938,"lines":72},{"path":"docs/adr/README.md","sha256":"1b2c7540b4c3c8d3f142886076e88d6db84e26fa778faad41dafdf093fee57b5","bytes":1982,"lines":19},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/doctoring/candidate-application-references.md","sha256":"202ca866ee16f6e61b11fcbe7ac712d308da189284b73c34a5121fa1e7495b0c","bytes":2962,"lines":24},{"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":"docs/traceability/candidate-application-core.md","sha256":"3ea6f15ea0cef7695bd4aca88a7da01f0c19bb0f4dac87b401d82b02bf152e14","bytes":6877,"lines":35},{"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":"bc72a46d34fee31f2193b9613a456c1f88d430b3598b4f722293750ff7f0f054","bytes":29306,"lines":711},{"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":"8379a54da48316cf3e2c96015a7d140463d874e8ed5c37cf066567e84a1708e1","bytes":16196,"lines":420},{"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_application_decision_boundary_postgres.sh","sha256":"887f20aba2c0e376fa0678da33e606a27cbf3bd8d149dd8cb2654fc264f0f416","bytes":3954,"lines":98},{"path":"tests/test_candidate_application_postgres.sh","sha256":"c8865795dd1d53fb534c8a9e0ca9fb7c226fd86dba8f39cf6ec21dc7c1d5e5e4","bytes":14893,"lines":241},{"path":"tests/test_candidate_application_rls_postgres.sh","sha256":"0f0ae93d28bb88430e377c05654250d2567ea0ff6c9b5c41365779fe91e2a563","bytes":4637,"lines":118},{"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":"381c66677324c09d9f05a266bc81efc8f88ce47fb3f5dca5073e5b0c4122a4c7","bytes":28733,"lines":675}]} +{ + "package": "orgmetra-foundation-pack", + "version": "0.1.0", + "generated_for_branch": "feat/normalized-candidate-application", + "files": [ + { + "path": ".github/workflows/candidate-application-quality.yml", + "sha256": "3386ec450a411a57a4e3c3bfec1558287cd04a229131f39873cc49f3e301c931", + "bytes": 3467, + "lines": 81 + }, + { + "path": ".github/workflows/candidate-withdrawal-quality.yml", + "sha256": "02599129aaa235f308f4d028c8a5aec4a912a890e0464c4a8bfb6f71ea0285ad", + "bytes": 4021, + "lines": 107 + }, + { + "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": "3552972c2cbbe2e0c0dd085ed2ff7e7d1828b06e767e6b14e1ca9fda9c2391de", + "bytes": 19077, + "lines": 80 + }, + { + "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/0014_candidate_application_core.sql", + "sha256": "0e46b67d24dec348d0282b1a15822131c7b7f35a14204855a092e37661c67dad", + "bytes": 13889, + "lines": 307 + }, + { + "path": "database/migrations/0015_candidate_withdrawal_governance.sql", + "sha256": "5b5a6fc372734be8fd7e0c994cba5a640e029037f2042bfc8f3f61ba9e29b005", + "bytes": 8376, + "lines": 195 + }, + { + "path": "database/migrations/0016_candidate_withdrawal_audit_envelope.sql", + "sha256": "af5d33853c591cdb52e11ec68c3ffe0e2c296e66582ced1e9c0f5c1150a0a768", + "bytes": 9104, + "lines": 243 + }, + { + "path": "docs/API_CONTRACT.md", + "sha256": "63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589", + "bytes": 4555, + "lines": 76 + }, + { + "path": "docs/DATA_MODEL.md", + "sha256": "9565f012162a0111ab29c5135207cf01d38af64d3e1def8e1a937293b2e3edf6", + "bytes": 14913, + "lines": 90 + }, + { + "path": "docs/ERD.md", + "sha256": "f979d4de859d40332ef8cfa5182c9ea16b0244583c49250a5d77a588af574246", + "bytes": 8331, + "lines": 77 + }, + { + "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/0026-normalized-candidate-application.md", + "sha256": "ecc901bf444c06e27c7e3dbdbeb6cf9bbded2f9450c52563721464ac6dbca081", + "bytes": 9945, + "lines": 73 + }, + { + "path": "docs/adr/0027-governed-candidate-withdrawal.md", + "sha256": "8170601fba5907bcf055a03941d3e736e6b0c58f7d1b6b57e26d1b2912e210a6", + "bytes": 8132, + "lines": 86 + }, + { + "path": "docs/adr/README.md", + "sha256": "213125c10cd13a30cfd5c2a3c62b64a1863b0b8735618377786763763139d23b", + "bytes": 2278, + "lines": 22 + }, + { + "path": "docs/doctoring/REFERENCES.md", + "sha256": "929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5", + "bytes": 6352, + "lines": 69 + }, + { + "path": "docs/doctoring/candidate-application-references.md", + "sha256": "202ca866ee16f6e61b11fcbe7ac712d308da189284b73c34a5121fa1e7495b0c", + "bytes": 2962, + "lines": 24 + }, + { + "path": "docs/doctoring/candidate-withdrawal-references.md", + "sha256": "a49be5ae37725158d7da7a82f6cc60df122f2cb086e1234bcecd5c85a41242c2", + "bytes": 3923, + "lines": 39 + }, + { + "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": "docs/traceability/candidate-application-core.md", + "sha256": "3ea6f15ea0cef7695bd4aca88a7da01f0c19bb0f4dac87b401d82b02bf152e14", + "bytes": 6877, + "lines": 35 + }, + { + "path": "docs/traceability/candidate-withdrawal.md", + "sha256": "100d752880f0d4341676b05d46334df10709a4b808c76969dd3aa7dfd63e638b", + "bytes": 4983, + "lines": 32 + }, + { + "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": "fbc590c889baacc420ea22dfa03cb54b74053f544379bf17056efe4d5477748f", + "bytes": 29888, + "lines": 721 + }, + { + "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": "8379a54da48316cf3e2c96015a7d140463d874e8ed5c37cf066567e84a1708e1", + "bytes": 16196, + "lines": 420 + }, + { + "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_application_decision_boundary_postgres.sh", + "sha256": "887f20aba2c0e376fa0678da33e606a27cbf3bd8d149dd8cb2654fc264f0f416", + "bytes": 3954, + "lines": 98 + }, + { + "path": "tests/test_candidate_application_postgres.sh", + "sha256": "c8865795dd1d53fb534c8a9e0ca9fb7c226fd86dba8f39cf6ec21dc7c1d5e5e4", + "bytes": 14893, + "lines": 241 + }, + { + "path": "tests/test_candidate_application_rls_postgres.sh", + "sha256": "0f0ae93d28bb88430e377c05654250d2567ea0ff6c9b5c41365779fe91e2a563", + "bytes": 4637, + "lines": 118 + }, + { + "path": "tests/test_candidate_withdrawal_migration_upgrade_postgres.sh", + "sha256": "4f281601b349f65e54a7f14506d5d429739ab7e9144c6aaaee0fcc3c9e872374", + "bytes": 2681, + "lines": 70 + }, + { + "path": "tests/test_candidate_withdrawal_postgres.sh", + "sha256": "5447dbe60ead96ece5502741888a4696474730c1e8efc4f46577d7ec2990463b", + "bytes": 8371, + "lines": 240 + }, + { + "path": "tests/test_candidate_withdrawal_security_postgres.sh", + "sha256": "0eaa30a0053859e1cf8e573ca2189bd569b87c940a55478a8f80370964f47ec7", + "bytes": 12375, + "lines": 317 + }, + { + "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": "18e977ed50d842eda3b75dd77ff167fe0f81938ab1bab6a6f31d5c5eabaa22a9", + "bytes": 29271, + "lines": 684 + } + ] +} diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index a8e12c8b5..69e679fda 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -53,8 +53,10 @@ export const REQUIRED_FILES = Object.freeze([ 'docs/adr/0013-governed-requisition-review-packet.md', 'docs/adr/0014-job-analysis-snapshot-persistence.md', 'docs/adr/0026-normalized-candidate-application.md', + 'docs/adr/0027-governed-candidate-withdrawal.md', 'docs/doctoring/REFERENCES.md', 'docs/doctoring/candidate-application-references.md', + 'docs/doctoring/candidate-withdrawal-references.md', 'docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md', 'docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md', 'database/migrations/0001_foundation_schema.sql', @@ -70,10 +72,14 @@ export const REQUIRED_FILES = Object.freeze([ 'database/migrations/0011_criterion_observation_scope.sql', 'database/migrations/0012_people_mutation_idempotency.sql', 'database/migrations/0013_job_analysis_snapshot.sql', - 'database/migrations/0014_candidate_application_core.sql', - 'docs/traceability/candidate-application-core.md', - '.github/workflows/candidate-application-quality.yml', - 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', + 'database/migrations/0014_candidate_application_core.sql', + 'database/migrations/0015_candidate_withdrawal_governance.sql', + 'database/migrations/0016_candidate_withdrawal_audit_envelope.sql', + 'docs/traceability/candidate-application-core.md', + 'docs/traceability/candidate-withdrawal.md', + '.github/workflows/candidate-application-quality.yml', + '.github/workflows/candidate-withdrawal-quality.yml', + 'packages/hris-kernel/src/orgmetra_hris_kernel/audit.py', 'packages/hris-kernel/tests/test_audit_outbox.py', 'schemas/openapi.yaml', 'scripts/foundation-contract-core.mjs', @@ -97,6 +103,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_candidate_withdrawal_postgres.sh', + 'tests/test_candidate_withdrawal_security_postgres.sh', + 'tests/test_candidate_withdrawal_migration_upgrade_postgres.sh', 'tests/validate_repository.py' ]); @@ -124,7 +133,7 @@ export const DATABASE_OBJECT_NAMES = Object.freeze([ 'job_analysis_snapshot', 'job_analysis_task_item', 'job_analysis_ksao_item', 'job_analysis_task_ksao_link', 'job_analysis_write_command', 'candidate_application_record', 'candidate_application_record_version', - 'candidate_application_stage_record', + 'candidate_application_stage_record', 'candidate_withdrawal_record', 'qualification_rule', 'candidate_profile', 'requisition_record', 'application_record', 'application_stage_history', 'decision_evidence_set', 'selection_decision_evidence', @@ -161,7 +170,8 @@ export const MIGRATION_BACKED_DATABASE_OBJECT_NAMES = Object.freeze([ 'validity_study_case_record', 'candidate_application_record', 'candidate_application_record_version', - 'candidate_application_stage_record' + 'candidate_application_stage_record', + 'candidate_withdrawal_record' ]); const UNFINISHED_MARKER_LINE_PATTERN = /^\s*(?:#{1,6}\s+|[-*+]\s+)?(?:\[(?:TODO|TBD|FIXME)\]|\{\{(?:TODO|TBD|FIXME)\}\}|<(?:TODO|TBD|FIXME)>|(?:TODO|TBD|FIXME)(?:\s*:\s*.*)?\s*)$/i; diff --git a/tests/test_candidate_withdrawal_migration_upgrade_postgres.sh b/tests/test_candidate_withdrawal_migration_upgrade_postgres.sh new file mode 100644 index 000000000..a5be2ac18 --- /dev/null +++ b/tests/test_candidate_withdrawal_migration_upgrade_postgres.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +# Migration 0008 is already applied in deployed databases. Prove the new +# withdrawal envelope is rejected by that legacy validator and accepted only +# after the forward repair migration is applied. +for migration in database/migrations/000{1..8}_*.sql; do + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +withdrawal_validation() { + psql "${DATABASE_URL}" -Atq <<'SQL' +WITH envelope AS ( + SELECT jsonb_build_object( + 'specversion', '1.0', + 'id', '00000000-0000-4000-8000-0000000000c1', + 'source', 'urn:orgmetra:talent_acquisition', + 'type', 'orgmetra.candidate.application_withdrawn', + 'subject', 'candidate_withdrawal_record:00000000-0000-4000-8000-0000000000c2', + 'time', '2026-08-21T09:10:00Z', + 'datacontenttype', 'application/json', + 'orgmetratenant', '10000000-0000-7000-8000-000000000001', + 'orgmetraactor', 'candidate:00000000-0000-4000-8000-0000000000c3', + 'orgmetrapurpose', 'candidate_withdrawal', + 'orgmetrareason', 'candidate_requested', + 'orgmetraevidence', 'candidate_withdrawal_evidence:00000000-0000-4000-8000-0000000000c4', + 'data', jsonb_build_object( + 'high_impact', false, + 'result_code', 'application_withdrawn', + 'evidence_version', 1, + 'identity_resolution_reference', 'identity_resolution:00000000-0000-4000-8000-0000000000c5', + 'identity_resolution_digest', repeat('a', 64), + 'withdrawal_evidence_digest', repeat('b', 64) + ) + )::text AS canonical_event_json +), payload AS ( + SELECT + canonical_event_json, + encode(digest(convert_to(canonical_event_json, 'UTF8'), 'sha256'), 'hex') + AS event_digest + FROM envelope +) +SELECT public.validate_audit_event_envelope( + canonical_event_json, + '00000000-0000-4000-8000-0000000000c1'::uuid, + '10000000-0000-7000-8000-000000000001'::uuid, + event_digest +) +FROM payload; +SQL +} + +legacy_result="$(withdrawal_validation)" +if [[ "${legacy_result}" != "f" ]]; then + echo "legacy migration 0008 unexpectedly accepted a candidate withdrawal envelope: ${legacy_result}" >&2 + exit 1 +fi + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 \ + -f database/migrations/0016_candidate_withdrawal_audit_envelope.sql + +upgraded_result="$(withdrawal_validation)" +if [[ "${upgraded_result}" != "t" ]]; then + echo "forward migration 0016 did not enable candidate withdrawal envelopes: ${upgraded_result}" >&2 + exit 1 +fi + +echo "candidate withdrawal migration upgrade contract passed" diff --git a/tests/test_candidate_withdrawal_postgres.sh b/tests/test_candidate_withdrawal_postgres.sh new file mode 100644 index 000000000..ddf4ba6bd --- /dev/null +++ b/tests/test_candidate_withdrawal_postgres.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +# This is the executable persistence contract for candidate-initiated withdrawal. +# It applies the complete stack, proves a governed withdrawal can be recorded, +# proves the raw application-stage shortcut remains closed, and proves the +# resulting governance evidence cannot be rewritten. +for migration in database/migrations/*.sql; do + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -f "${migration}" +done + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO tenant_record (tenant_record_id, tenant_reference) +VALUES + ('10000000-0000-7000-8000-000000000001', 'tenant_alpha'), + ('20000000-0000-7000-8000-000000000001', 'tenant_beta'); + +INSERT INTO candidate_profile ( + tenant_record_id, candidate_profile_id, application_status_code, recorded_from +) VALUES + ( + '10000000-0000-7000-8000-000000000001', + '10000000-0000-7000-8000-000000000011', + 'legacy_unscoped', + TIMESTAMPTZ '2026-08-21 09:00:00+00' + ), + ( + '20000000-0000-7000-8000-000000000001', + '20000000-0000-7000-8000-000000000011', + 'legacy_unscoped', + TIMESTAMPTZ '2026-08-21 09:00:00+00' + ); + +INSERT INTO candidate_application_record ( + tenant_record_id, candidate_application_record_id, candidate_profile_id, + requisition_reference, submitted_at, recorded_from +) VALUES + ( + '10000000-0000-7000-8000-000000000001', + '10000000-0000-7000-8000-000000000051', + '10000000-0000-7000-8000-000000000011', + 'requisition:11111111-1111-4111-8111-111111111111', + TIMESTAMPTZ '2026-08-21 09:10:00+00', + TIMESTAMPTZ '2026-08-21 09:10:01+00' + ), + ( + '20000000-0000-7000-8000-000000000001', + '20000000-0000-7000-8000-000000000051', + '20000000-0000-7000-8000-000000000011', + 'requisition:22222222-2222-4222-8222-222222222222', + TIMESTAMPTZ '2026-08-21 09:10:00+00', + TIMESTAMPTZ '2026-08-21 09:10:01+00' + ); +SQL + +record_event() { + local event_id="$1" + local outbox_id="$2" + local tenant_id="$3" + local subject="$4" + local actor="$5" + local evidence="$6" + local event_time="$7" + local identity_reference="$8" + local identity_digest="$9" + local withdrawal_digest="${10}" + local evidence_version="${11}" + + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 \ + --set=event_id="${event_id}" \ + --set=outbox_id="${outbox_id}" \ + --set=tenant_id="${tenant_id}" \ + --set=subject="${subject}" \ + --set=actor="${actor}" \ + --set=evidence="${evidence}" \ + --set=event_time="${event_time}" \ + --set=identity_reference="${identity_reference}" \ + --set=identity_digest="${identity_digest}" \ + --set=withdrawal_digest="${withdrawal_digest}" \ + --set=evidence_version="${evidence_version}" <<'SQL' +WITH envelope AS ( + SELECT jsonb_build_object( + 'specversion', '1.0', + 'id', :'event_id', + 'source', 'urn:orgmetra:talent_acquisition', + 'type', 'orgmetra.candidate.application_withdrawn', + 'subject', :'subject', + 'time', :'event_time', + 'datacontenttype', 'application/json', + 'orgmetratenant', :'tenant_id', + 'orgmetraactor', :'actor', + 'orgmetrapurpose', 'candidate_withdrawal', + 'orgmetrareason', 'candidate_requested', + 'orgmetraevidence', :'evidence', + 'data', jsonb_build_object( + 'high_impact', false, + 'result_code', 'application_withdrawn', + 'evidence_version', :'evidence_version'::integer, + 'identity_resolution_reference', :'identity_reference', + 'identity_resolution_digest', :'identity_digest', + 'withdrawal_evidence_digest', :'withdrawal_digest' + ) + )::text AS canonical_event_json +), payload AS ( + SELECT + canonical_event_json, + encode(digest(convert_to(canonical_event_json, 'UTF8'), 'sha256'), 'hex') + AS event_digest + FROM envelope +) +SELECT record_audit_outbox_event( + :'tenant_id'::uuid, + :'event_id'::uuid, + :'outbox_id'::uuid, + canonical_event_json, + event_digest, + 'talent_acquisition_events' +) +FROM payload; +SQL +} + +record_event \ + '70000000-0000-7000-8000-000000000001' \ + '71000000-0000-7000-8000-000000000001' \ + '10000000-0000-7000-8000-000000000001' \ + 'candidate_withdrawal_record:72000000-0000-7000-8000-000000000001' \ + 'candidate:73000000-0000-4000-8000-000000000001' \ + 'candidate_withdrawal_evidence:74000000-0000-4000-8000-000000000001' \ + '2026-08-21T09:20:00Z' \ + 'identity_resolution:75000000-0000-4000-8000-000000000001' \ + "$(printf 'a%.0s' {1..64})" \ + "$(printf 'b%.0s' {1..64})" \ + '1' + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO candidate_withdrawal_record ( + tenant_record_id, + candidate_withdrawal_record_id, + candidate_application_record_id, + initiating_actor_reference, + identity_resolution_reference, + identity_resolution_digest, + withdrawal_evidence_reference, + withdrawal_evidence_digest, + evidence_version, + withdrawn_at, + audit_event_record_id, + recorded_at +) VALUES ( + '10000000-0000-7000-8000-000000000001', + '72000000-0000-7000-8000-000000000001', + '10000000-0000-7000-8000-000000000051', + 'candidate:73000000-0000-4000-8000-000000000001', + 'identity_resolution:75000000-0000-4000-8000-000000000001', + repeat('a', 64), + 'candidate_withdrawal_evidence:74000000-0000-4000-8000-000000000001', + repeat('b', 64), + 1, + TIMESTAMPTZ '2026-08-21 09:20:00+00', + '70000000-0000-7000-8000-000000000001', + -- The trigger must replace this caller-supplied future system time. + TIMESTAMPTZ '2099-01-01 00:00:00+00' +); +SQL + +persisted_count="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT count(*) +FROM candidate_withdrawal_record +WHERE tenant_record_id = '10000000-0000-7000-8000-000000000001'::uuid + AND candidate_application_record_id = '10000000-0000-7000-8000-000000000051'::uuid; +")" +if [[ "${persisted_count}" != "1" ]]; then + echo "governed candidate withdrawal was not persisted exactly once" >&2 + exit 1 +fi + +recorded_at_is_system_time="$(psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -Atqc " +SELECT (recorded_at <= transaction_timestamp())::text +FROM candidate_withdrawal_record +WHERE tenant_record_id = '10000000-0000-7000-8000-000000000001'::uuid + AND candidate_withdrawal_record_id = '72000000-0000-7000-8000-000000000001'::uuid; +")" +if [[ "${recorded_at_is_system_time}" != "true" ]]; then + echo "candidate withdrawal accepted caller-controlled future recorded_at" >&2 + exit 1 +fi + +set +e +raw_stage_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO candidate_application_stage_record ( + tenant_record_id, + candidate_application_stage_record_id, + candidate_application_record_id, + application_stage_code, + effective_from, + recorded_from +) VALUES ( + '20000000-0000-7000-8000-000000000001', + '20000000-0000-7000-8000-000000000061', + '20000000-0000-7000-8000-000000000051', + 'withdrawn', + TIMESTAMPTZ '2026-08-21 09:20:00+00', + TIMESTAMPTZ '2026-08-21 09:20:01+00' +); +SQL +} 2>&1)" +raw_stage_status=$? +set -e +if [[ ${raw_stage_status} -eq 0 ]]; then + echo "raw application stage reintroduced unproven withdrawn state" >&2 + exit 1 +fi +if [[ "${raw_stage_output}" != *"candidate_application_stage_code_check"* ]]; then + echo "raw withdrawn stage failed for an unexpected reason: ${raw_stage_output}" >&2 + exit 1 +fi + +set +e +rewrite_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +UPDATE candidate_withdrawal_record +SET initiating_actor_reference = 'candidate:73000000-0000-4000-8000-000000000099' +WHERE tenant_record_id = '10000000-0000-7000-8000-000000000001' + AND candidate_withdrawal_record_id = '72000000-0000-7000-8000-000000000001'; +SQL +} 2>&1)" +rewrite_status=$? +set -e +if [[ ${rewrite_status} -eq 0 ]]; then + echo "candidate withdrawal evidence was rewritten in place" >&2 + exit 1 +fi +if [[ "${rewrite_output}" != *"candidate withdrawal evidence is append-only"* ]]; then + echo "withdrawal rewrite failed for an unexpected reason: ${rewrite_output}" >&2 + exit 1 +fi + +echo "candidate withdrawal persistence contract passed" diff --git a/tests/test_candidate_withdrawal_security_postgres.sh b/tests/test_candidate_withdrawal_security_postgres.sh new file mode 100644 index 000000000..7c9afe0f6 --- /dev/null +++ b/tests/test_candidate_withdrawal_security_postgres.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DATABASE_URL:=postgresql://orgmetra:orgmetra@localhost:5432/orgmetra}" + +# This security contract intentionally runs after test_candidate_withdrawal_postgres.sh +# in the same PostgreSQL service. The predecessor script owns schema/fixture setup; +# this script isolates adversarial cases so each rejection has one causal boundary. +record_event() { + local event_id="$1" + local outbox_id="$2" + local tenant_id="$3" + local subject="$4" + local actor="$5" + local evidence="$6" + local event_time="$7" + local identity_reference="$8" + local identity_digest="$9" + local withdrawal_digest="${10}" + local evidence_version="${11}" + + psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 \ + --set=event_id="${event_id}" \ + --set=outbox_id="${outbox_id}" \ + --set=tenant_id="${tenant_id}" \ + --set=subject="${subject}" \ + --set=actor="${actor}" \ + --set=evidence="${evidence}" \ + --set=event_time="${event_time}" \ + --set=identity_reference="${identity_reference}" \ + --set=identity_digest="${identity_digest}" \ + --set=withdrawal_digest="${withdrawal_digest}" \ + --set=evidence_version="${evidence_version}" <<'SQL' +WITH envelope AS ( + SELECT jsonb_build_object( + 'specversion', '1.0', + 'id', :'event_id', + 'source', 'urn:orgmetra:talent_acquisition', + 'type', 'orgmetra.candidate.application_withdrawn', + 'subject', :'subject', + 'time', :'event_time', + 'datacontenttype', 'application/json', + 'orgmetratenant', :'tenant_id', + 'orgmetraactor', :'actor', + 'orgmetrapurpose', 'candidate_withdrawal', + 'orgmetrareason', 'candidate_requested', + 'orgmetraevidence', :'evidence', + 'data', jsonb_build_object( + 'high_impact', false, + 'result_code', 'application_withdrawn', + 'evidence_version', :'evidence_version'::integer, + 'identity_resolution_reference', :'identity_reference', + 'identity_resolution_digest', :'identity_digest', + 'withdrawal_evidence_digest', :'withdrawal_digest' + ) + )::text AS canonical_event_json +), payload AS ( + SELECT + canonical_event_json, + encode(digest(convert_to(canonical_event_json, 'UTF8'), 'sha256'), 'hex') + AS event_digest + FROM envelope +) +SELECT record_audit_outbox_event( + :'tenant_id'::uuid, + :'event_id'::uuid, + :'outbox_id'::uuid, + canonical_event_json, + event_digest, + 'talent_acquisition_events' +) +FROM payload; +SQL +} + +# A second otherwise-valid audit event must not allow a second withdrawal for the +# same application. Using a distinct audit ID isolates the application uniqueness +# invariant from audit-event uniqueness. +record_event \ + '70000000-0000-7000-8000-000000000002' \ + '71000000-0000-7000-8000-000000000002' \ + '10000000-0000-7000-8000-000000000001' \ + 'candidate_withdrawal_record:72000000-0000-7000-8000-000000000002' \ + 'candidate:73000000-0000-4000-8000-000000000001' \ + 'candidate_withdrawal_evidence:74000000-0000-4000-8000-000000000002' \ + '2026-08-21T09:21:00Z' \ + 'identity_resolution:75000000-0000-4000-8000-000000000002' \ + "$(printf 'c%.0s' {1..64})" \ + "$(printf 'd%.0s' {1..64})" \ + '2' + +set +e +duplicate_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO candidate_withdrawal_record ( + tenant_record_id, candidate_withdrawal_record_id, + candidate_application_record_id, initiating_actor_reference, + identity_resolution_reference, identity_resolution_digest, + withdrawal_evidence_reference, withdrawal_evidence_digest, + evidence_version, withdrawn_at, audit_event_record_id, recorded_at +) VALUES ( + '10000000-0000-7000-8000-000000000001', + '72000000-0000-7000-8000-000000000002', + '10000000-0000-7000-8000-000000000051', + 'candidate:73000000-0000-4000-8000-000000000001', + 'identity_resolution:75000000-0000-4000-8000-000000000002', + repeat('c', 64), + 'candidate_withdrawal_evidence:74000000-0000-4000-8000-000000000002', + repeat('d', 64), + 2, + TIMESTAMPTZ '2026-08-21 09:21:00+00', + '70000000-0000-7000-8000-000000000002', + TIMESTAMPTZ '2026-08-21 09:21:01+00' +); +SQL +} 2>&1)" +duplicate_status=$? +set -e +if [[ ${duplicate_status} -eq 0 || "${duplicate_output}" != *"candidate_withdrawal_application_unique"* ]]; then + echo "duplicate withdrawal did not fail at the application uniqueness boundary: ${duplicate_output}" >&2 + exit 1 +fi + +# A fully valid generic audit envelope with a staff actor must still be rejected by +# the withdrawal relation itself. This prevents the generic audit API from being +# used to relabel a staff-driven adverse action as candidate withdrawal. +record_event \ + '80000000-0000-7000-8000-000000000001' \ + '81000000-0000-7000-8000-000000000001' \ + '20000000-0000-7000-8000-000000000001' \ + 'candidate_withdrawal_record:82000000-0000-7000-8000-000000000001' \ + 'staff:83000000-0000-4000-8000-000000000001' \ + 'candidate_withdrawal_evidence:84000000-0000-4000-8000-000000000001' \ + '2026-08-21T09:20:00Z' \ + 'identity_resolution:85000000-0000-4000-8000-000000000001' \ + "$(printf 'e%.0s' {1..64})" \ + "$(printf 'f%.0s' {1..64})" \ + '1' + +set +e +staff_actor_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO candidate_withdrawal_record ( + tenant_record_id, candidate_withdrawal_record_id, + candidate_application_record_id, initiating_actor_reference, + identity_resolution_reference, identity_resolution_digest, + withdrawal_evidence_reference, withdrawal_evidence_digest, + evidence_version, withdrawn_at, audit_event_record_id, recorded_at +) VALUES ( + '20000000-0000-7000-8000-000000000001', + '82000000-0000-7000-8000-000000000001', + '20000000-0000-7000-8000-000000000051', + 'staff:83000000-0000-4000-8000-000000000001', + 'identity_resolution:85000000-0000-4000-8000-000000000001', + repeat('e', 64), + 'candidate_withdrawal_evidence:84000000-0000-4000-8000-000000000001', + repeat('f', 64), + 1, + TIMESTAMPTZ '2026-08-21 09:20:00+00', + '80000000-0000-7000-8000-000000000001', + TIMESTAMPTZ '2026-08-21 09:20:01+00' +); +SQL +} 2>&1)" +staff_actor_status=$? +set -e +if [[ ${staff_actor_status} -eq 0 || "${staff_actor_output}" != *"candidate_withdrawal_actor_reference_check"* ]]; then + echo "staff actor could masquerade as candidate withdrawal: ${staff_actor_output}" >&2 + exit 1 +fi + +# Candidate-shaped actor text is not enough: the immutable audit envelope must bind +# the exact evidence reference, identity-resolution reference/digest, withdrawal +# digest, and evidence version supplied by the withdrawal row. +record_event \ + '80000000-0000-7000-8000-000000000002' \ + '81000000-0000-7000-8000-000000000002' \ + '20000000-0000-7000-8000-000000000001' \ + 'candidate_withdrawal_record:82000000-0000-7000-8000-000000000002' \ + 'candidate:83000000-0000-4000-8000-000000000002' \ + 'candidate_withdrawal_evidence:84000000-0000-4000-8000-000000000002' \ + '2026-08-21T09:22:00Z' \ + 'identity_resolution:85000000-0000-4000-8000-000000000002' \ + "$(printf '1%.0s' {1..64})" \ + "$(printf '2%.0s' {1..64})" \ + '1' + +set +e +forged_evidence_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO candidate_withdrawal_record ( + tenant_record_id, candidate_withdrawal_record_id, + candidate_application_record_id, initiating_actor_reference, + identity_resolution_reference, identity_resolution_digest, + withdrawal_evidence_reference, withdrawal_evidence_digest, + evidence_version, withdrawn_at, audit_event_record_id, recorded_at +) VALUES ( + '20000000-0000-7000-8000-000000000001', + '82000000-0000-7000-8000-000000000002', + '20000000-0000-7000-8000-000000000051', + 'candidate:83000000-0000-4000-8000-000000000002', + 'identity_resolution:85000000-0000-4000-8000-000000000099', + repeat('9', 64), + 'candidate_withdrawal_evidence:84000000-0000-4000-8000-000000000002', + repeat('8', 64), + 3, + TIMESTAMPTZ '2026-08-21 09:22:00+00', + '80000000-0000-7000-8000-000000000002', + TIMESTAMPTZ '2026-08-21 09:22:01+00' +); +SQL +} 2>&1)" +forged_evidence_status=$? +set -e +if [[ ${forged_evidence_status} -eq 0 || "${forged_evidence_output}" != *"candidate withdrawal audit envelope does not bind exact candidate provenance"* ]]; then + echo "withdrawal accepted audit/evidence mismatch: ${forged_evidence_output}" >&2 + exit 1 +fi + +# Persist one valid Beta withdrawal so RLS can prove positive visibility in both +# tenants rather than only proving absence. +record_event \ + '80000000-0000-7000-8000-000000000003' \ + '81000000-0000-7000-8000-000000000003' \ + '20000000-0000-7000-8000-000000000001' \ + 'candidate_withdrawal_record:82000000-0000-7000-8000-000000000003' \ + 'candidate:83000000-0000-4000-8000-000000000003' \ + 'candidate_withdrawal_evidence:84000000-0000-4000-8000-000000000003' \ + '2026-08-21T09:23:00Z' \ + 'identity_resolution:85000000-0000-4000-8000-000000000003' \ + "$(printf '3%.0s' {1..64})" \ + "$(printf '4%.0s' {1..64})" \ + '1' + +psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 <<'SQL' +INSERT INTO candidate_withdrawal_record ( + tenant_record_id, candidate_withdrawal_record_id, + candidate_application_record_id, initiating_actor_reference, + identity_resolution_reference, identity_resolution_digest, + withdrawal_evidence_reference, withdrawal_evidence_digest, + evidence_version, withdrawn_at, audit_event_record_id, recorded_at +) VALUES ( + '20000000-0000-7000-8000-000000000001', + '82000000-0000-7000-8000-000000000003', + '20000000-0000-7000-8000-000000000051', + 'candidate:83000000-0000-4000-8000-000000000003', + 'identity_resolution:85000000-0000-4000-8000-000000000003', + repeat('3', 64), + 'candidate_withdrawal_evidence:84000000-0000-4000-8000-000000000003', + repeat('4', 64), + 1, + TIMESTAMPTZ '2026-08-21 09:23:00+00', + '80000000-0000-7000-8000-000000000003', + TIMESTAMPTZ '2026-08-21 09:23:01+00' +); + +CREATE ROLE orgmetra_candidate_withdrawal_reader NOLOGIN NOBYPASSRLS; +GRANT USAGE ON SCHEMA public TO orgmetra_candidate_withdrawal_reader; +GRANT SELECT ON candidate_withdrawal_record TO orgmetra_candidate_withdrawal_reader; + +SET ROLE orgmetra_candidate_withdrawal_reader; + +DO $$ +DECLARE + visible_count bigint; +BEGIN + SELECT count(*) INTO visible_count FROM candidate_withdrawal_record; + IF visible_count <> 0 THEN + RAISE EXCEPTION 'missing tenant context exposed candidate withdrawal evidence: %', visible_count; + END IF; +END; +$$; + +SET orgmetra.tenant_record_id = '10000000-0000-7000-8000-000000000001'; +DO $$ +DECLARE + visible_count bigint; + wrong_tenant_count bigint; +BEGIN + SELECT count(*) INTO visible_count FROM candidate_withdrawal_record; + SELECT count(*) INTO wrong_tenant_count + FROM candidate_withdrawal_record + WHERE tenant_record_id <> '10000000-0000-7000-8000-000000000001'::uuid; + IF visible_count <> 1 OR wrong_tenant_count <> 0 THEN + RAISE EXCEPTION 'tenant Alpha withdrawal visibility was not isolated: visible=%, wrong=%', + visible_count, wrong_tenant_count; + END IF; +END; +$$; + +SET orgmetra.tenant_record_id = '20000000-0000-7000-8000-000000000001'; +DO $$ +DECLARE + visible_count bigint; + wrong_tenant_count bigint; +BEGIN + SELECT count(*) INTO visible_count FROM candidate_withdrawal_record; + SELECT count(*) INTO wrong_tenant_count + FROM candidate_withdrawal_record + WHERE tenant_record_id <> '20000000-0000-7000-8000-000000000001'::uuid; + IF visible_count <> 1 OR wrong_tenant_count <> 0 THEN + RAISE EXCEPTION 'tenant Beta withdrawal visibility was not isolated: visible=%, wrong=%', + visible_count, wrong_tenant_count; + END IF; +END; +$$; + +RESET ROLE; +SQL + +set +e +truncate_output="$({ psql "${DATABASE_URL}" -v ON_ERROR_STOP=1 -c 'TRUNCATE candidate_withdrawal_record;' ; } 2>&1)" +truncate_status=$? +set -e +if [[ ${truncate_status} -eq 0 || "${truncate_output}" != *"candidate withdrawal evidence cannot be truncated"* ]]; then + echo "candidate withdrawal evidence could be truncated or failed unexpectedly: ${truncate_output}" >&2 + exit 1 +fi + +echo "candidate withdrawal anti-forgery and tenant-isolation contract passed" diff --git a/tests/validate_repository.py b/tests/validate_repository.py index 86abffbd6..ddfd29a0d 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -57,8 +57,10 @@ "docs/adr/0013-governed-requisition-review-packet.md", "docs/adr/0014-job-analysis-snapshot-persistence.md", "docs/adr/0026-normalized-candidate-application.md", + "docs/adr/0027-governed-candidate-withdrawal.md", "docs/doctoring/REFERENCES.md", "docs/doctoring/candidate-application-references.md", + "docs/doctoring/candidate-withdrawal-references.md", "docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md", "docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md", "database/migrations/0001_foundation_schema.sql", @@ -75,8 +77,12 @@ "database/migrations/0012_people_mutation_idempotency.sql", "database/migrations/0013_job_analysis_snapshot.sql", "database/migrations/0014_candidate_application_core.sql", + "database/migrations/0015_candidate_withdrawal_governance.sql", + "database/migrations/0016_candidate_withdrawal_audit_envelope.sql", "docs/traceability/candidate-application-core.md", + "docs/traceability/candidate-withdrawal.md", ".github/workflows/candidate-application-quality.yml", + ".github/workflows/candidate-withdrawal-quality.yml", "packages/hris-kernel/src/orgmetra_hris_kernel/audit.py", "packages/hris-kernel/tests/test_audit_outbox.py", "schemas/openapi.yaml", @@ -101,6 +107,9 @@ "tests/test_criterion_observation_scope_postgres.sh", "tests/test_people_mutation_idempotency_postgres.sh", "tests/test_job_analysis_snapshot_postgres.sh", + "tests/test_candidate_withdrawal_postgres.sh", + "tests/test_candidate_withdrawal_security_postgres.sh", + "tests/test_candidate_withdrawal_migration_upgrade_postgres.sh", "tests/validate_repository.py", ]