diff --git a/.github/workflows/employment-history-postgres-quality.yml b/.github/workflows/employment-history-postgres-quality.yml new file mode 100644 index 00000000..26f59829 --- /dev/null +++ b/.github/workflows/employment-history-postgres-quality.yml @@ -0,0 +1,61 @@ +name: Employment History PostgreSQL Read Quality + +on: + pull_request: + branches: + - develop + - feat/employment-history-http-read + paths: + - "services/people-api/**" + - "packages/hris-kernel/**" + - "packages/keyverse-adapter/**" + - ".github/requirements/foundation-test.txt" + - ".github/workflows/employment-history-postgres-quality.yml" + - "database/migrations/0001_foundation_schema.sql" + - "docs/adr/0156-employment-history-postgres-read.md" + - "docs/doctoring/postgres-employment-history-read-references.md" + - "docs/traceability/postgres-employment-history-read.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: employment-history-postgres-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: PostgreSQL Employment-history read contract + runs-on: ubuntu-latest + timeout-minutes: 10 + 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: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + check-latest: false + - name: Install reviewed test toolchain + run: | + python -m pip install --require-hashes --no-deps --only-binary=:all: -r .github/requirements/foundation-test.txt + python -m pip check + - name: Compile People API boundary + run: python -m compileall -q services/people-api/src packages/hris-kernel/src packages/keyverse-adapter/src services/people-api/tests + - name: Test governed People contracts with exact statement and branch coverage + env: + PYTHONPATH: services/people-api/src:packages/hris-kernel/src:packages/keyverse-adapter/src + COVERAGE_FILE: /tmp/orgmetra-employment-history-postgres.coverage + run: python -m pytest -c services/people-api/pyproject.toml services/people-api/tests + - name: Require clean checkout + run: | + git diff --exit-code + test -z "$(git status --porcelain)" diff --git a/CHANGELOG.md b/CHANGELOG.md index a664c3e9..7d093a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Orgmetra will be documented in this file. - Accepted ADRs 0001–0003 now include buyer-facing Context, Decision, and Consequences grounded in verified ISO 30400:2022, ISO 30414:2025, Uniform Guidelines (29 C.F.R. Part 1607), SIOP (2018), OpenAPI Specification v3.2.0, OpenID Connect Core 1.0 errata set 2, CloudEvents v1.0.2, Jensen and Snodgrass (1999), Snodgrass (1999), and Allen (1983) records already listed in `docs/doctoring/REFERENCES.md`. ADRs 0004 and 0005 gained APA 7th References pointers to that same bibliography without changing their Decision bodies. - Active stacked PR #155 adds the customer-callable `EmploymentHistoryAsgiApp` read route, `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history`, with exact UTC knowledge-cutoff parsing, purpose/field authorization through the existing Employment-history service, minimized entries, no-store response controls, client-safe errors, a published OpenAPI 3.2 contract, and a dedicated exact-head 100% People API quality workflow. It does not mutate Employment or make an employment decision. +- Active stacked PR #156 adds `PostgresEmploymentHistoryReadPort` behind the existing Employment-history service boundary. It provides exact operational identity/time validation, read-only tenant-scoped parameterized bitemporal SQL, explicit UTC timestamp projection, malformed-row and visibility rechecks, immutable typed results, a dedicated exact-head 100% People API quality workflow, and real PostgreSQL 16.14 validation. It does not authorize fields, mutate HRIS truth, or make an employment decision. - Active-PR governed Job Analysis persistence/API on the canonical `JobAnalysisSnapshot` model: migration `0013_job_analysis_snapshot.sql` stores immutable tenant-scoped snapshot, Task, KSAO, Task–KSAO, FJA and write-command evidence; `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots` and matching GET enforce purpose-bound Keyverse scope, authenticated-principal actor authority, bounded/strict JSON handling, transactional Idempotency-Key serialization, parent-scope fail-closed integrity, forced RLS, and atomic audit/outbox evidence. ADR 0014 records the persistence decision while ADR 0007 remains the domain/evidence authority; validated evidence still requires accountable human review and non-LLM provenance, and the service does not make a high-impact employment decision. - Active-PR `orgmetra_selection_review` packet for PII-minimized, evidence-bound human selection review: canonical operational tenant identity, UUID-backed opaque candidate/Job/sealed-evidence/reviewer references, explicit purpose/reason/evidence version, deterministic canonical JSON and SHA-256 correlation, mandatory human decision state, redacted packet repr, and provenance-paired model evidence that remains `untrusted_draft`, with exact 100% owned statement and branch coverage required by its quality gate. - 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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f474fde2..4392dc9f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -49,6 +49,8 @@ Employment-history service. The response contains only authorized and maps integrity or unexpected backend failures to client-safe opaque support references. The route adds no mutation or employment-decision authority. +`PostgresEmploymentHistoryReadPort` is a persistence defense-in-depth boundary, not an authorization substitute. It validates exact operational tenant/Person identities and UTC knowledge time before connection acquisition, uses a read-only transaction and transaction-local tenant setting, restricts the query to the canonical Employment tables, and rechecks returned identity and recorded-time visibility. It accepts no purpose, scope, token, or raw credential and therefore cannot widen the parent service's purpose-bound field decision. + ## Mutation security contract Every mutating HTTP operation and its server-side command handler requires one validated `Idempotency-Key` that crosses the command boundary into durable transactional replay state. The published OpenAPI employment, position, assignment, person, job-profile, and selection-decision command families require `X-Tenant-Reference`, `X-Actor-Reference`, and `X-Purpose-Code`; those values must match the authenticated Keyverse principal and the operation-specific least-privilege scope. The executable People mutation handlers added on this branch currently implement employment, position, and assignment creation with those headers. Person, job-profile, and selection-decision remain published foundation API contracts until their server handlers are integrated; their OpenAPI presence is not runtime evidence. Confirmed-hire materialization instead binds the tenant in `/v1/tenants/{tenant_record_id}/candidate-worker-conversions`, the business purpose in its exact query parameter, and the actor through the authenticated principal. It does not accept weaker duplicate actor/tenant/purpose header authorities. diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 850c701f..efccf3a6 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -33,6 +33,7 @@ The command runs Python repository-integrity validation, the dependency-free Nod | Governed People mutation idempotency: tenant/route/key uniqueness, identical-command replay, changed-command rejection, rollback safety, append-only/TRUNCATE protection, forced RLS and concurrent exact-key serialization | `bash tests/test_people_mutation_idempotency_postgres.sh` against PostgreSQL 16 in Foundation CI | | Tenant/actor/purpose authorization matrix and negative high-impact commands | service-specific unit and integration test commands recorded in each service package | | Employment-history HTTP read parsing, authentication order, purpose/field authorization, bitemporal cutoff forwarding, response minimization, client-safe errors, and exact 100% statement/branch coverage | `PYTHONPATH=services/people-api/src:packages/hris-kernel/src:packages/keyverse-adapter/src python -m pytest -c services/people-api/pyproject.toml services/people-api/tests` and `.github/workflows/employment-history-http-quality.yml` | +| Employment-history PostgreSQL adapter identity/time validation, read-only transaction ordering, tenant context, explicit bitemporal SQL, UTC projection, DB-API integrity, immutable typed output, and seeded PostgreSQL 16.14 execution | `PYTHONPATH=services/people-api/src:packages/hris-kernel/src:packages/keyverse-adapter/src python -m pytest -c services/people-api/pyproject.toml services/people-api/tests`, isolated PostgreSQL 16.14 validation, and `.github/workflows/employment-history-postgres-quality.yml` | | AsyncAPI/CloudEvents envelope compatibility | provider and consumer contract test commands recorded beside the versioned event schema | | External adapter timeout, malformed response, tenant mismatch, and unavailable-state handling | fake-server tests in each adapter package | | Role-workspace keyboard, focus, exact-value, permission-denied, and confirmation states | Storybook interaction/a11y tests plus browser E2E for the owning workspace | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 8b21cb5d..4efc5d19 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,6 +15,7 @@ | Governed candidate-to-worker conversion | Talent Acquisition / People core | `candidate_worker_conversion_record` with candidate, person, employment, selection decision, audit event and outbox evidence | PostgreSQL exact hire/evidence/audit-envelope binding, correction provenance, tenant RLS, legacy-write rejection and bitemporal history contract | ADR-0001, ADR-0003, ADR-0006 | implemented_on_protected_main | | GET-only People API | People API / purpose-bound read boundary | `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}`, `read_worker_people_record()`, `PostgresPeopleReadPort` | People API HTTP and PostgreSQL read contracts with exact 100% owned statement/branch coverage; current conversion lineage; no mutation writes | ADR-0002, ADR-0008 | implemented_on_protected_main | | Purpose-bound Employment-history HTTP read | People API / customer read boundary | `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history`, `EmploymentHistoryAsgiApp`, `read_employment_history()`, `EmploymentHistoryReadPort` | exact path/query validation before authentication; Keyverse scope and purpose/field authorization; UTC bitemporal cutoff; minimized entries; client-safe 400/401/403/409/500 errors; exact 100% People API statement/branch coverage | ADR-0008, ADR-0149, ADR-0155 | implemented_on_active_pr | +| Canonical Employment-history PostgreSQL read | People API / HRIS persistence boundary | `PostgresEmploymentHistoryReadPort`, `employment_record`, `employment_record_version` | exact operational identity/time validation; read-only transaction and tenant context; parameterized bitemporal SQL; UTC projection; malformed-row, target-mismatch, cutoff, immutable-result, and real PostgreSQL 16.14 regressions; exact 100% People API statement/branch coverage | ADR-0003, ADR-0008, ADR-0156 | implemented_on_active_pr | | Governed People writes and confirmed-hire materialization | People API / purpose-bound mutation boundary | `POST /v1/employment-records`, `POST /v1/position-records`, `POST /v1/assignment-records`, `POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions`, `people_mutation_idempotency_record` | People command/HTTP/PostgreSQL contracts with exact owned statement/branch coverage plus PostgreSQL tenant-RLS, atomic audit/outbox/idempotency, identical-retry replay, changed-command rejection, rollback, and concurrent-key regression | ADR-0002, ADR-0006, ADR-0008 | implemented_on_protected_main | | Evidence-grounded Job analysis with governed Task/FJA/KSAO persistence | Job Analysis / Workforce Validation | `JobAnalysisSnapshot`, `TaskEvidence`, `KSAORequirement`, `FunctionalJobAnalysisProfile`, `TaskKSAOLink`, `EvidenceSource`, `job_analysis_snapshot`, `job_analysis_task_item`, `job_analysis_ksao_item`, `job_analysis_task_ksao_link`, `job_analysis_write_command`, `POST /v1/tenants/{tenant_record_id}/job-analysis-snapshots`, `GET /v1/tenants/{tenant_record_id}/job-analysis-snapshots/{analysis_record_id}` | domain tenant/Job isolation, source/version/digest provenance, task-KSAO completeness, deterministic canonicalization, accountable human-review and LLM-draft-only regressions; migration 0013 PostgreSQL parent-scope/RLS/append-only/idempotency/audit-outbox persistence; exact route/OpenAPI/error contracts and 100% owned service statement/branch coverage | ADR-0007, ADR-0014 | implemented_on_active_pr | | Job-, cycle-, and staffing-scoped performance criterion observations | Performance / Workforce Validation | `criterion_observation`, `criterion_blueprint`, `performance_cycle`, `assignment_record`, `employment_record_version`, `position_record`, `position_record_version` | PostgreSQL wrong-Job, pre-assignment, out-of-cycle, frozen-Position, terminated-employment, closed-recorded-time, and session-TimeZone/UTC-midnight rejection plus valid worker-Job/staffing acceptance | ADR-0009 | implemented_on_protected_main | diff --git a/docs/adr/0156-employment-history-postgres-read.md b/docs/adr/0156-employment-history-postgres-read.md new file mode 100644 index 00000000..97f96e8e --- /dev/null +++ b/docs/adr/0156-employment-history-postgres-read.md @@ -0,0 +1,48 @@ +# ADR 0156: Read Employment history from canonical PostgreSQL truth + +- **Status:** Proposed on active stacked PR #156; not protected-main truth until integrated +- **Date:** 2026-08-30 +- **Owners:** Orgmetra People API / HRIS persistence +- **Extends:** ADR 0003 (bitemporal HRIS data), ADR 0008 (purpose-bound PII authorization), ADR 0149 (Employment-history read contract), ADR 0155 (Employment-history HTTP read) + +## Context + +PR #155 exposes the customer-callable Employment-history HTTP boundary but keeps persistence injected. An integrated deployment still needs one canonical adapter for normalized `employment_record` and `employment_record_version` truth; otherwise each host could supply persistence code with different tenant or system-time semantics. + +The adapter is not an authorization engine or a second source of truth. The parent People service authorizes before calling it and revalidates its typed output before disclosure. The existing schema owns Employment identity, Person binding, bitemporal version facts, tenant RLS, and immutable-history guards. + +## Decision + +Add `PostgresEmploymentHistoryReadPort` as the PostgreSQL implementation of the `EmploymentHistoryReadPort` protocol. + +The adapter: + +1. validates exact operational tenant/Person UUIDs and an exact built-in UTC `known_at` before acquiring a connection; +2. opens one `READ COMMITTED, READ ONLY` transaction and sets the transaction-local tenant context before the protected query; +3. joins only Orgmetra-owned `employment_record_version` to its `employment_record` anchor, preserving Person scope without joining another bounded context's application tables; +4. applies explicit tenant, Person, parent-recorded, and version-recorded half-open predicates; +5. projects recorded timestamps with `AT TIME ZONE 'UTC'`, accepts only exact naive UTC DB projections, and attaches built-in UTC after validation; and +6. treats DB-API output as untrusted by checking the default list collection, exact tuple row shape, domain reconstruction, requested target identity, and knowledge-cutoff visibility before returning an immutable tuple. + +Purpose-bound field authorization remains in the parent service. This adapter performs no mutation, audit/outbox write, foreign-service call, disclosure, or high-impact employment decision. + +## Consequences + +### Positive + +- The Employment-history application contract can use canonical normalized PostgreSQL truth without host-specific persistence code. +- Read-only transaction mode, explicit predicates, and tenant context provide layered database scope controls. +- Person, Employment identity, and Employment-version history remain separate while business-effective time stays distinct from system-recorded visibility. +- Exact DB timestamp validation prevents driver/session timezone behavior from changing evidence meaning. + +### Trade-offs + +- The adapter is PostgreSQL/DB-API specific and intentionally requires the default tuple-row contract. +- Database RLS and bitemporal constraints still require independent PostgreSQL tests; this adapter does not claim SQL predicates replace authorization or schema constraints. +- The parent service must continue to revalidate rows before serialization. + +## Verification + +The contract-first child test head `1a8b9fb7` fails during collection while the adapter module is absent. The final child must show exact-current-head full People API coverage, invalid-input zero-connection behavior, transaction ordering, explicit SQL scope, UTC projection, malformed-row rejection, target/visibility rechecks, immutable results, a real PostgreSQL 16.14 seeded-database validation, and a clean checkout. Parent #155 evidence does not transfer. + +The implementation follows PostgreSQL transaction access-mode guidance and the existing protected Orgmetra RLS contract. These controls are defense in depth and do not authorize a merge or protected-main representation while this PR is Draft or central gates lack authoritative verdicts. diff --git a/docs/adr/README.md b/docs/adr/README.md index cac94e4e..f4218860 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,3 +17,4 @@ | [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 | | [0155](0155-employment-history-http-read.md) | Expose governed Employment history through a read-only HTTP boundary | Proposed on active stacked PR #155 | +| [0156](0156-employment-history-postgres-read.md) | Read Employment history from canonical PostgreSQL truth | Proposed on active stacked PR #156 | diff --git a/docs/doctoring/postgres-employment-history-read-references.md b/docs/doctoring/postgres-employment-history-read-references.md new file mode 100644 index 00000000..ae2986a7 --- /dev/null +++ b/docs/doctoring/postgres-employment-history-read-references.md @@ -0,0 +1,29 @@ +# PostgreSQL Employment-history read references + +**Scope:** Standards and research basis for active PR #156. This file does not claim certification or protected-main integration. + +## APA 7 references + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53, Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). RFC Editor. https://doi.org/10.17487/RFC3339 + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Range types*. https://www.postgresql.org/docs/current/rangetypes.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: Row security policies*. https://www.postgresql.org/docs/current/ddl-rowsecurity.html + +PostgreSQL Global Development Group. (2026). *PostgreSQL 18 documentation: SET TRANSACTION*. https://www.postgresql.org/docs/current/sql-set-transaction.html + +Snodgrass, R. T. (1999). *Developing time-oriented database applications in SQL*. Morgan Kaufmann. https://lccn.loc.gov/99014298 + +## Decision relevance + +PostgreSQL transaction access mode and isolation support the adapter's explicit `READ COMMITTED, READ ONLY` boundary. Row security remains database defense in depth, while the application binds tenant context and checks exact returned identity. Range and exclusion semantics remain the schema-level basis for bitemporal non-overlap; this read adapter does not replace those constraints. + +RFC 3339 supports one interoperable UTC representation for system-recorded evidence. Jensen and Snodgrass and Snodgrass support keeping effective/business time distinct from transaction/system time. NIST SP 800-53 Rev. 5 informs least privilege and information-integrity evidence readiness; no compliance or certification claim follows from this PR. + +## Research classification + +These references constrain the accepted adapter architecture for PR #156. They do not authorize scope expansion into compensation, candidate, performance, credentials, or employment-decision automation. diff --git a/docs/traceability/postgres-employment-history-read.md b/docs/traceability/postgres-employment-history-read.md new file mode 100644 index 00000000..77a15231 --- /dev/null +++ b/docs/traceability/postgres-employment-history-read.md @@ -0,0 +1,41 @@ +# PostgreSQL Employment-history read traceability + +**Lifecycle status:** Active stacked PR #156 only. This document does not claim protected-`develop` integration. + +## Buyer problem + +PR #155 defines a customer-callable, purpose-bound Employment-history read but leaves persistence injected. Without a canonical adapter, an Orgmetra deployment cannot obtain that bounded history from normalized `employment_record` and `employment_record_version` truth without bespoke host code. + +## Requirement-to-evidence matrix + +| Requirement | Production boundary | Regression | +| --- | --- | --- | +| No DB access on invalid input | exact tenant/Person UUID and built-in UTC `known_at` validation before `connection_factory()` | invalid UUID/time cases assert zero connection calls | +| Database cannot mutate HR truth | `SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ ONLY` | SQL execution-order assertion | +| Tenant defense in depth | transaction-local `pg_catalog.set_config('orgmetra.tenant_record_id', ..., true)` before SELECT | exact SQL and parameter assertion | +| Explicit Employment/Person scope | fully qualified join between `public.employment_record_version` and `public.employment_record` with tenant/Person predicates | SQL contract assertions | +| Preserve system knowledge | half-open parent/version `recorded_from`/`recorded_to` predicates at `known_at` | future and closed-at-cutoff rows fail closed | +| Preserve business history | no effective-date filter; deterministic effective start/Employment/version ordering | returned typed dates and SQL ordering assertion | +| Canonical UTC | `AT TIME ZONE 'UTC'` projection and exact naive DB timestamp validation | string/aware/non-datetime timestamp regressions | +| Untrusted DB-API boundary | exact list result, exact tuple row shape, domain reconstruction | malformed collection/row/value regressions | +| Immutable typed result | tuple of `EmploymentHistoryRecord` values | empty and non-empty result regressions | +| Parent authority remains single owner | adapter accepts no purpose or authorization input | PR #155 performs authorization and service revalidation | + +## Test-first chain + +1. **Contract-only child head:** `1a8b9fb7` adds the adapter regressions while `orgmetra_people_api.postgres_employment_history` is absent. +2. **Expected RED:** local and exact hosted collection must fail with `ModuleNotFoundError` at that owning module boundary; predecessor or parent failures are not relabeled as adapter evidence. +3. **Implementation:** add the smallest adapter and package-root export, then rerun the full People API suite with exact statement and branch coverage. +4. **Database validation:** apply migration `0001_foundation_schema.sql` to an isolated PostgreSQL 16.14 database, seed anonymized tenant/Person/Employment facts, and execute the adapter through psycopg. +5. **Hosted evidence rule:** only the final exact current child head's dedicated workflow and applicable central checks may be used for advancement. Parent #155 evidence does not transfer. + +## Security and data boundary + +The adapter reads only Employment anchor identity/Person binding and Employment-version fields. It does not join organization, Job, Position, Assignment, compensation, candidate, performance, credential, prompt, or model-output data. Purpose-bound authorization-before-retrieval remains in the parent service; the adapter performs no mutation, audit/outbox write, or high-impact employment decision. + +## Out of scope + +- Employment-history HTTP/presentation integration; PR #155 owns that boundary. +- Employment mutation or correction workflows. +- Database migrations; the protected schema already owns these relations and RLS policies. +- Release, tag, publication, or protected-default-branch authority. diff --git a/manifest.json b/manifest.json index 9a7e571b..7702b40f 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"c68893c18d6e7c4b118f977a27fe6a6e7e46b04268225dc8b1a550c9696c5d5a","bytes":17811,"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":"docs/API_CONTRACT.md","sha256":"8857996b3a0d9cb6c970bec495cbba6b5d7859fd03bafc1237fa5fff695eabc4","bytes":4944,"lines":77},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"70f073f3f9b86f824efc04706f2a299bfd77e7c2d23d5999eba3c06691e39186","bytes":11860,"lines":74},{"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":"2b89cc75fcdec89cdc390bbd26e1653d17d6b5699596fdd14c38ff695ee7ab6d","bytes":16976,"lines":136},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"8fe78c72fd45fb28af64da727e41b9164c61052331cb1a12fe9c1d8a10b795ca","bytes":12006,"lines":41},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"cf0f41fa4b783ae77607ba5d21076bf8f2af92b975e49e6585b112211ce949fa","bytes":6625,"lines":148},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"820fd9556036344e39012f1ddd1b70b6593ea6dd02b8adaefaaa15f8ed3d1815","bytes":1995,"lines":19},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"a0513cc34b33bb2deedada4b706daeeba69c5239af16a586639a90ab62661f3d","bytes":32417,"lines":1110},{"path":"scripts/foundation-contract-core.mjs","sha256":"beb4c251f912c687ff08d01cf6791d66ac7d8e3f0d767d857a8f164b190c3040","bytes":29406,"lines":707},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"cf0269695fb3c3ceb9ea1fa1bcddb463e56a64753b0f953200b7609f8be7dfb0","bytes":7170,"lines":215},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"7ea0d34c61b921e9d7c3566b5095604e72b67cca5924497a0b6ce73bd01452db","bytes":28737,"lines":677}]} +{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"11e0b20e305e97a56e031c351b653f09f9f2ed8baf3423c7e38e2cf16f9bd616","bytes":18319,"lines":78},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"8857996b3a0d9cb6c970bec495cbba6b5d7859fd03bafc1237fa5fff695eabc4","bytes":4944,"lines":77},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"6f702d2f927d539fcf42db75ac217f7a2b5450d0b040efabe6debccd0009eb2a","bytes":12401,"lines":76},{"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":"ff4ebcc7133f77d630b489473f22541685e7a8de467d774ac8ef127542e0ae8a","bytes":17491,"lines":137},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"f4967514f72f8468c831b4a45f6c864907adcd9d0b167923013c6e63a78d4bec","bytes":12521,"lines":42},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"cf0f41fa4b783ae77607ba5d21076bf8f2af92b975e49e6585b112211ce949fa","bytes":6625,"lines":148},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"f750ff9c3bed1534134f628f1877e802a20913f4378b1e0616f6b84c2dd4afbd","bytes":2143,"lines":20},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"a0513cc34b33bb2deedada4b706daeeba69c5239af16a586639a90ab62661f3d","bytes":32417,"lines":1110},{"path":"scripts/foundation-contract-core.mjs","sha256":"beb4c251f912c687ff08d01cf6791d66ac7d8e3f0d767d857a8f164b190c3040","bytes":29406,"lines":707},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"cf0269695fb3c3ceb9ea1fa1bcddb463e56a64753b0f953200b7609f8be7dfb0","bytes":7170,"lines":215},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"7ea0d34c61b921e9d7c3566b5095604e72b67cca5924497a0b6ce73bd01452db","bytes":28737,"lines":677}]} diff --git a/services/people-api/README.md b/services/people-api/README.md index a01a0eab..d73906ae 100644 --- a/services/people-api/README.md +++ b/services/people-api/README.md @@ -12,6 +12,8 @@ The service exposes a governed hire-to-employment read contract. `read_worker_pe `EmploymentHistoryAsgiApp` exposes the governed Employment-history use case as `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history?known_at=YYYY-MM-DDTHH:MM:SSZ&purpose=employee_profile_review&fields=...`. It validates the exact path and query shape before authentication, delegates the tenant/Person/purpose/field decision and bitemporal snapshot to `read_employment_history()`, and returns only authorized `entries[].fields` with `Cache-Control: no-store` and `Vary: Authorization`. The route is read-only: it does not query another service's application tables, mutate Employment, or make an employment decision. +`PostgresEmploymentHistoryReadPort` is the canonical persistence adapter for that injected read port. It validates operational tenant/Person identity and UTC knowledge time before acquiring a connection, starts a read-only transaction, binds the transaction-local tenant context, and executes one parameterized join over Orgmetra-owned `employment_record` and `employment_record_version` tables. It enforces parent/version recorded-time visibility, projects database timestamps explicitly as UTC, rejects malformed or mismatched DB-API rows, and returns immutable typed records. Authorization, field minimization, pooling, credentials, TLS, and database-role selection remain outside this adapter. + The People API quality workflow is part of this contract and must run for pull requests to every supported protected/default integration branch, including `develop`. Its service tests enforce 100% owned statement and branch coverage and include regression coverage for the workflow dispatch boundary and HTTP security/transport behavior. `HireAcceptanceAsgiApp` exposes confirmed-hire materialization as `POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candidate_hire`. Authentication and tenant binding occur before request-body parsing, so an unauthenticated or foreign-tenant caller cannot use body parsing or command construction as an oracle. Authenticated requests then pass the validated `Idempotency-Key`, content-type, JSON/schema, authorization, and governed command checks under a 64 KiB cumulative request-body limit, at most 1024 ASGI request frames, and 128 nested JSON containers below the top-level command object. `PostgresHireAcceptancePort` acquires a transaction-scoped lock for the tenant/route/key before it persists Person, Employment, `candidate_worker_conversion_record`, governed audit/outbox evidence, and `people_mutation_idempotency_record` in one tenant-bound transaction. An exact retry returns the first committed person/employment/conversion identities without repeating necessary PII, audit, or outbox writes; reusing the key for changed command semantics fails closed. The legacy `candidate_worker_link` write path is not used. diff --git a/services/people-api/src/orgmetra_people_api/__init__.py b/services/people-api/src/orgmetra_people_api/__init__.py index 1899ef30..75896561 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -50,6 +50,7 @@ read_worker_people_record, ) from orgmetra_people_api.postgres import PostgresPeopleReadPort +from orgmetra_people_api.postgres_employment_history import PostgresEmploymentHistoryReadPort from orgmetra_people_api.postgres_hire import PostgresHireAcceptancePort from orgmetra_people_api.postgres_mutations import PostgresPeopleMutationPort @@ -86,6 +87,7 @@ "PostgresHireAcceptancePort", "PostgresPeopleMutationPort", "PostgresPeopleReadPort", + "PostgresEmploymentHistoryReadPort", "TokenAuthenticator", "WorkerPeopleRecord", "accept_confirmed_hire", diff --git a/services/people-api/src/orgmetra_people_api/postgres_employment_history.py b/services/people-api/src/orgmetra_people_api/postgres_employment_history.py new file mode 100644 index 00000000..d34a9931 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/postgres_employment_history.py @@ -0,0 +1,179 @@ +"""PostgreSQL adapter for purpose-bound Employment-history reads. + +The parent People service owns purpose-bound authorization. This adapter owns +only a read-only, tenant-scoped projection of canonical Orgmetra Employment +and Employment-version facts, returning typed rows for the parent service to +revalidate before disclosure. +""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Callable +from uuid import UUID + +from orgmetra_people_api.employment_history import ( + EmploymentHistoryIntegrityError, + EmploymentHistoryRecord, +) + +PostgresConnectionFactory = Callable[[], AbstractContextManager[Any]] + +_READ_ONLY_SQL = "SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ ONLY" +_TENANT_CONTEXT_SQL = "SELECT pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" +_EMPLOYMENT_HISTORY_SQL = """ +SELECT + employment_version.tenant_record_id, + employment.person_record_id, + employment_version.employment_record_id, + employment_version.employment_record_version_id, + employment_version.employment_status_code, + employment_version.employment_concurrency_code, + employment_version.effective_from, + employment_version.effective_to, + employment_version.recorded_from AT TIME ZONE 'UTC' AS recorded_from_utc, + employment_version.recorded_to AT TIME ZONE 'UTC' AS recorded_to_utc +FROM public.employment_record_version AS employment_version +JOIN public.employment_record AS employment + ON employment.tenant_record_id = employment_version.tenant_record_id + AND employment.employment_record_id = employment_version.employment_record_id +WHERE employment_version.tenant_record_id = %s + AND employment.person_record_id = %s + AND employment.recorded_from <= %s + AND (employment.recorded_to IS NULL OR %s < employment.recorded_to) + AND employment_version.recorded_from <= %s + AND (employment_version.recorded_to IS NULL OR %s < employment_version.recorded_to) +ORDER BY employment_version.effective_from, + employment_version.employment_record_id, + employment_version.employment_record_version_id +""".strip() +_MAX_UUID_INT = (1 << 128) - 1 + + +def _require_operational_uuid(field_name: str, value: object) -> None: + """Require an exact non-sentinel UUID before any database access.""" + if type(value) is not UUID: + raise ValueError(f"{field_name} must be an operational UUID.") + if value.int in (0, _MAX_UUID_INT): + raise ValueError(f"{field_name} must be an operational UUID.") + + +def _require_utc_instant(field_name: str, value: object) -> None: + """Require exact built-in UTC time before using it as a history cutoff.""" + if type(value) is not datetime: + raise ValueError(f"{field_name} must be a timezone-aware UTC datetime.") + if type(value.tzinfo) is not timezone: + raise ValueError(f"{field_name} must be a timezone-aware UTC datetime.") + if value.utcoffset() != timedelta(0): + raise ValueError(f"{field_name} must be a timezone-aware UTC datetime.") + + +def _db_utc_instant(value: object) -> datetime: + """Attach built-in UTC only to PostgreSQL's explicit naive UTC projection.""" + if type(value) is not datetime or value.tzinfo is not None: + raise EmploymentHistoryIntegrityError( + "database recorded time must be a naive UTC projection" + ) + return value.replace(tzinfo=timezone.utc) + + +def _record_from_row(row: object) -> EmploymentHistoryRecord: + """Convert one untrusted DB-API row into the parent governed record type.""" + if type(row) is not tuple or len(row) != 10: + raise EmploymentHistoryIntegrityError("database Employment-history row has an invalid shape") + ( + tenant_record_id, + person_record_id, + employment_record_id, + employment_record_version_id, + employment_status_code, + employment_concurrency_code, + effective_from, + effective_to, + recorded_from, + recorded_to, + ) = row + try: + return EmploymentHistoryRecord( + tenant_record_id=tenant_record_id, + person_record_id=person_record_id, + employment_record_id=employment_record_id, + employment_record_version_id=employment_record_version_id, + employment_status_code=employment_status_code, + employment_concurrency_code=employment_concurrency_code, + effective_from=effective_from, + effective_to=effective_to, + recorded_from=_db_utc_instant(recorded_from), + recorded_to=None if recorded_to is None else _db_utc_instant(recorded_to), + ) + except ValueError as exc: + raise EmploymentHistoryIntegrityError( + "database Employment-history row failed integrity" + ) from exc + + +@dataclass(frozen=True, slots=True) +class PostgresEmploymentHistoryReadPort: + """Read canonical Employment history through a tenant-scoped read-only transaction.""" + + connection_factory: PostgresConnectionFactory + + def __post_init__(self) -> None: + """Reject an unusable connection factory before a protected read can start.""" + if not callable(self.connection_factory): + raise TypeError("connection_factory must be callable") + + def read_employment_history( + self, + *, + tenant_record_id: UUID, + person_record_id: UUID, + known_at: datetime, + ) -> tuple[EmploymentHistoryRecord, ...]: + """Return Employment versions visible at ``known_at`` without authorizing disclosure.""" + _require_operational_uuid("tenant_record_id", tenant_record_id) + _require_operational_uuid("person_record_id", person_record_id) + _require_utc_instant("known_at", known_at) + + with self.connection_factory() as connection: + with connection.cursor() as cursor: + cursor.execute(_READ_ONLY_SQL) + cursor.execute(_TENANT_CONTEXT_SQL, (str(tenant_record_id),)) + cursor.execute( + _EMPLOYMENT_HISTORY_SQL, + ( + tenant_record_id, + person_record_id, + known_at, + known_at, + known_at, + known_at, + ), + ) + rows = cursor.fetchall() + + if type(rows) is not list: + raise EmploymentHistoryIntegrityError( + "database Employment-history read must return the default list row collection" + ) + + records: list[EmploymentHistoryRecord] = [] + for row in rows: + record = _record_from_row(row) + if ( + record.tenant_record_id != tenant_record_id + or record.person_record_id != person_record_id + ): + raise EmploymentHistoryIntegrityError( + "database Employment-history row does not match the requested target" + ) + if record.recorded_from > known_at or ( + record.recorded_to is not None and known_at >= record.recorded_to + ): + raise EmploymentHistoryIntegrityError( + "database Employment-history row is not visible at the requested knowledge cutoff" + ) + records.append(record) + return tuple(records) diff --git a/services/people-api/tests/test_postgres_employment_history.py b/services/people-api/tests/test_postgres_employment_history.py new file mode 100644 index 00000000..908fde6a --- /dev/null +++ b/services/people-api/tests/test_postgres_employment_history.py @@ -0,0 +1,332 @@ +"""Regression contracts for the PostgreSQL Employment-history read adapter.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from datetime import date, datetime, timedelta, timezone, tzinfo +from uuid import UUID + +import pytest + +from orgmetra_people_api.employment_history import EmploymentHistoryIntegrityError +from orgmetra_people_api.postgres_employment_history import PostgresEmploymentHistoryReadPort + +TENANT_ID = UUID("0198a415-9ab1-7000-8000-000000000001") +PERSON_ID = UUID("0198a415-9ab1-7000-8000-000000000002") +EMPLOYMENT_ID = UUID("0198a415-9ab1-7000-8000-000000000003") +VERSION_ID = UUID("0198a415-9ab1-7000-8000-000000000004") +KNOWN_AT = datetime(2026, 8, 30, tzinfo=timezone.utc) + + +class ForgedUUID(UUID): + """Prove caller-controlled UUID subclasses cannot cross the adapter boundary.""" + + +class ZeroOffsetProvider(tzinfo): + """Prove a caller-controlled zero-offset timezone is not canonical UTC.""" + + def utcoffset(self, dt: datetime | None) -> timedelta: + """Return zero only to exercise the exact-timezone check.""" + del dt + return timedelta(0) + + def dst(self, dt: datetime | None) -> timedelta: + """Return zero daylight-saving offset for the test timezone.""" + del dt + return timedelta(0) + + +def employment_row( + *, + tenant_record_id: object = TENANT_ID, + person_record_id: object = PERSON_ID, + employment_record_id: object = EMPLOYMENT_ID, + employment_record_version_id: object = VERSION_ID, + employment_status_code: object = "active", + employment_concurrency_code: object = "exclusive", + effective_from: object = date(2026, 1, 1), + effective_to: object = date(2026, 7, 1), + recorded_from: object = datetime(2026, 8, 1), + recorded_to: object = None, +) -> tuple[object, ...]: + """Return one default DB row projected by the governed SQL query.""" + return ( + tenant_record_id, + person_record_id, + employment_record_id, + employment_record_version_id, + employment_status_code, + employment_concurrency_code, + effective_from, + effective_to, + recorded_from, + recorded_to, + ) + + +class FakeCursor(AbstractContextManager["FakeCursor"]): + """Minimal DB-API cursor that records SQL and returns configured rows.""" + + def __init__(self, rows: object) -> None: + self.rows = rows + self.executions: list[tuple[str, object | None]] = [] + + def __enter__(self) -> "FakeCursor": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + return None + + def execute(self, statement: str, parameters: object | None = None) -> None: + """Record each SQL statement and its bound parameters.""" + self.executions.append((statement, parameters)) + + def fetchall(self) -> object: + """Return the configured DB-API row collection.""" + return self.rows + + +class FakeConnection(AbstractContextManager["FakeConnection"]): + """Minimal connection exposing one stable cursor.""" + + def __init__(self, cursor: FakeCursor) -> None: + self._cursor = cursor + + def __enter__(self) -> "FakeConnection": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + del exc_type, exc, traceback + return None + + def cursor(self) -> FakeCursor: + """Return the deterministic cursor used by this transaction.""" + return self._cursor + + +class ConnectionFactory: + """Count connection acquisition so invalid inputs prove zero database access.""" + + def __init__(self, rows: object) -> None: + self.calls = 0 + self.cursor = FakeCursor(rows) + + def __call__(self) -> FakeConnection: + self.calls += 1 + return FakeConnection(self.cursor) + + +@pytest.mark.parametrize("invalid_factory", [None, 7, "connection"]) +def test_rejects_non_callable_connection_factory(invalid_factory: object) -> None: + with pytest.raises(TypeError, match="connection_factory must be callable"): + PostgresEmploymentHistoryReadPort(invalid_factory) # type: ignore[arg-type] + + +def test_read_is_tenant_scoped_read_only_bitemporal_and_typed() -> None: + factory = ConnectionFactory([employment_row(recorded_to=datetime(2026, 9, 1))]) + port = PostgresEmploymentHistoryReadPort(factory) + + records = port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + assert len(records) == 1 + record = records[0] + assert record.tenant_record_id == TENANT_ID + assert record.person_record_id == PERSON_ID + assert record.employment_record_id == EMPLOYMENT_ID + assert record.employment_record_version_id == VERSION_ID + assert record.employment_status_code == "active" + assert record.employment_concurrency_code == "exclusive" + assert record.effective_from == date(2026, 1, 1) + assert record.effective_to == date(2026, 7, 1) + assert record.recorded_from == datetime(2026, 8, 1, tzinfo=timezone.utc) + assert record.recorded_to == datetime(2026, 9, 1, tzinfo=timezone.utc) + assert factory.calls == 1 + + assert len(factory.cursor.executions) == 3 + transaction_sql, transaction_parameters = factory.cursor.executions[0] + tenant_sql, tenant_parameters = factory.cursor.executions[1] + history_sql, history_parameters = factory.cursor.executions[2] + assert transaction_sql == "SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ ONLY" + assert transaction_parameters is None + assert "pg_catalog.set_config('orgmetra.tenant_record_id', %s, true)" in tenant_sql + assert tenant_parameters == (str(TENANT_ID),) + assert "FROM public.employment_record_version AS employment_version" in history_sql + assert "JOIN public.employment_record AS employment" in history_sql + assert "employment_version.tenant_record_id = %s" in history_sql + assert "employment.person_record_id = %s" in history_sql + assert "employment.recorded_from <= %s" in history_sql + assert "%s < employment.recorded_to" in history_sql + assert "employment_version.recorded_from <= %s" in history_sql + assert "%s < employment_version.recorded_to" in history_sql + assert "AT TIME ZONE 'UTC'" in history_sql + assert "ORDER BY employment_version.effective_from" in history_sql + assert "SELECT *" not in history_sql.upper() + assert history_parameters == ( + TENANT_ID, + PERSON_ID, + KNOWN_AT, + KNOWN_AT, + KNOWN_AT, + KNOWN_AT, + ) + + +def test_empty_database_result_returns_immutable_empty_tuple() -> None: + factory = ConnectionFactory([]) + port = PostgresEmploymentHistoryReadPort(factory) + + assert port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) == () + + +@pytest.mark.parametrize( + ("tenant_record_id", "person_record_id", "known_at"), + [ + ("not-a-uuid", PERSON_ID, KNOWN_AT), + (ForgedUUID(str(TENANT_ID)), PERSON_ID, KNOWN_AT), + (UUID(int=0), PERSON_ID, KNOWN_AT), + (TENANT_ID, ForgedUUID(str(PERSON_ID)), KNOWN_AT), + (TENANT_ID, UUID(int=(1 << 128) - 1), KNOWN_AT), + (TENANT_ID, PERSON_ID, "2026-08-30"), + (TENANT_ID, PERSON_ID, datetime(2026, 8, 30)), + (TENANT_ID, PERSON_ID, datetime(2026, 8, 30, tzinfo=timezone(timedelta(hours=9)))), + (TENANT_ID, PERSON_ID, datetime(2026, 8, 30, tzinfo=ZeroOffsetProvider())), + ], +) +def test_invalid_request_identity_or_time_fails_before_database_access( + tenant_record_id: object, + person_record_id: object, + known_at: object, +) -> None: + factory = ConnectionFactory([]) + port = PostgresEmploymentHistoryReadPort(factory) + + with pytest.raises(ValueError): + port.read_employment_history( # type: ignore[arg-type] + tenant_record_id=tenant_record_id, + person_record_id=person_record_id, + known_at=known_at, + ) + + assert factory.calls == 0 + + +def test_rejects_non_default_fetchall_collection() -> None: + factory = ConnectionFactory((employment_row(),)) + port = PostgresEmploymentHistoryReadPort(factory) + + with pytest.raises(EmploymentHistoryIntegrityError, match="default list row collection"): + port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize("row", [[1] * 10, (1, 2)]) +def test_rejects_unsupported_row_container_or_shape(row: object) -> None: + factory = ConnectionFactory([row]) + port = PostgresEmploymentHistoryReadPort(factory) + + with pytest.raises(EmploymentHistoryIntegrityError, match="row has an invalid shape"): + port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize( + ("recorded_from", "recorded_to"), + [ + ("2026-08-01", None), + (datetime(2026, 8, 1, tzinfo=timezone.utc), None), + (datetime(2026, 8, 1), "2026-09-01"), + (datetime(2026, 8, 1), datetime(2026, 9, 1, tzinfo=timezone.utc)), + ], +) +def test_rejects_noncanonical_database_timestamp_projection( + recorded_from: object, + recorded_to: object, +) -> None: + factory = ConnectionFactory([employment_row(recorded_from=recorded_from, recorded_to=recorded_to)]) + port = PostgresEmploymentHistoryReadPort(factory) + + with pytest.raises(EmploymentHistoryIntegrityError, match="database recorded time must be a naive UTC projection"): + port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +def test_rejects_database_row_that_fails_employment_record_integrity() -> None: + factory = ConnectionFactory([employment_row(employment_status_code="NOT_CANONICAL")]) + port = PostgresEmploymentHistoryReadPort(factory) + + with pytest.raises(EmploymentHistoryIntegrityError, match="database Employment-history row failed integrity"): + port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize( + "row", + [ + employment_row(tenant_record_id=UUID("0198a415-9ab1-7000-8000-000000000011")), + employment_row(person_record_id=UUID("0198a415-9ab1-7000-8000-000000000012")), + ], +) +def test_rejects_row_outside_requested_tenant_or_person(row: tuple[object, ...]) -> None: + factory = ConnectionFactory([row]) + port = PostgresEmploymentHistoryReadPort(factory) + + with pytest.raises(EmploymentHistoryIntegrityError, match="does not match the requested target"): + port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +@pytest.mark.parametrize( + "row", + [ + employment_row(recorded_from=datetime(2026, 8, 31)), + employment_row(recorded_to=datetime(2026, 8, 30)), + ], +) +def test_rejects_row_outside_requested_system_knowledge_cutoff(row: tuple[object, ...]) -> None: + factory = ConnectionFactory([row]) + port = PostgresEmploymentHistoryReadPort(factory) + + with pytest.raises(EmploymentHistoryIntegrityError, match="not visible at the requested knowledge cutoff"): + port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + +def test_open_recorded_interval_is_visible_at_known_at() -> None: + factory = ConnectionFactory([employment_row()]) + port = PostgresEmploymentHistoryReadPort(factory) + + records = port.read_employment_history( + tenant_record_id=TENANT_ID, + person_record_id=PERSON_ID, + known_at=KNOWN_AT, + ) + + assert records[0].recorded_to is None + assert isinstance(records, tuple)