diff --git a/.github/workflows/employment-history-http-quality.yml b/.github/workflows/employment-history-http-quality.yml new file mode 100644 index 000000000..a63a8d0af --- /dev/null +++ b/.github/workflows/employment-history-http-quality.yml @@ -0,0 +1,66 @@ +name: Employment History HTTP Quality + +on: + pull_request: + branches: + - develop + - feat/people-employment-history-read + paths: + - "services/people-api/**" + - "packages/hris-kernel/**" + - "packages/keyverse-adapter/**" + - "schemas/openapi.yaml" + - ".github/requirements/foundation-test.txt" + - ".github/workflows/employment-history-http-quality.yml" + - "docs/API_CONTRACT.md" + - "docs/SECURITY.md" + - "docs/TEST_STRATEGY.md" + - "docs/TRACEABILITY.md" + - "docs/adr/0155-employment-history-http-read.md" + - "docs/doctoring/employment-history-http-read-references.md" + - "docs/traceability/employment-history-http-read.md" + - "services/people-api/README.md" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: employment-history-http-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Employment-history HTTP 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-http.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 99f4752d7..a664c3e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Orgmetra will be documented in this file. ### Added - 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-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/API_CONTRACT.md b/docs/API_CONTRACT.md index 27235d9c9..b43d2d1c5 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -52,6 +52,7 @@ POST /v1/tenants/{tenant_record_id}/candidate-worker-conversions?purpose=candida POST /v1/employment-records POST /v1/position-records POST /v1/assignment-records +GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history?known_at=...&purpose=...&fields=... POST /v1/job-profiles POST /v1/job-profiles/{job_profile_id}/publish POST /v1/candidate-profiles @@ -60,7 +61,7 @@ POST /v1/criterion-observations POST /v1/validity-studies ``` -The foundation OpenAPI contract covers the shared command vocabulary and baseline person, employment, position, assignment, job-profile, and selection-decision operations. Runtime services must publish any additional path-specific contract before release and may not weaken the shared `Idempotency-Key`, least-privilege scope, authorization, evidence, or error semantics. Employment and assignment writes fail closed when exclusive jobs overlap, a seat is not staffable, or visible seat allocations exceed 1.0000. +The foundation OpenAPI contract covers the shared command vocabulary and baseline person, employment, position, assignment, job-profile, selection-decision, and purpose-bound Employment-history read operations. Runtime services must publish any additional path-specific contract before release and may not weaken the shared `Idempotency-Key`, least-privilege scope, authorization, evidence, or error semantics. Employment and assignment writes fail closed when exclusive jobs overlap, a seat is not staffable, or visible seat allocations exceed 1.0000. The Employment-history read requires `orgmetra.people.employment_history.read`, an exact `known_at` UTC cutoff, a business purpose, and an explicit field list; it returns only policy-authorized entries and never mutates HRIS truth. ## Error shape @@ -73,4 +74,4 @@ The foundation OpenAPI contract covers the shared command vocabulary and baselin } ``` -`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. \ No newline at end of file +`support_reference` is a randomly generated client-safe lookup key. It maps to restricted internal telemetry but never encodes or exposes an internal trace/span identifier, topology, timestamp, tenant identifier, credential, or PII. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fd6dd3ea6..f474fde2a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -39,6 +39,16 @@ Evaluation fails closed unless request, actor, resource, and policy tenants all Authorization evidence contains only governance metadata, including the opaque actor and exact target-resource references, plus field names, never protected values. A denial returns a stable reason code and next safe action. An allow decision returns only the exact requested field subset, not every field the policy could permit. Both allow and denial evidence preserve the exact target reference so immutable audit correlation cannot collapse distinct person or employment records into one resource-kind-level event. These rules implement the Orgmetra side of the NIST SP 800-162 ABAC shape and attribute-integrity principles from NIST SP 800-205; ADR 0008 records the boundary. +The Employment-history HTTP read applies this boundary at the customer route: +`GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history`. +It validates the tenant, Person, UTC `known_at`, purpose, and explicit fields +before authentication; then the authenticated principal and +`orgmetra.people.employment_history.read` scope are evaluated by the existing +Employment-history service. The response contains only authorized +`entries[].fields`, uses `Cache-Control: no-store` and `Vary: Authorization`, +and maps integrity or unexpected backend failures to client-safe opaque support +references. The route adds no mutation or employment-decision authority. + ## 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 c20813b72..850c701ff 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -32,6 +32,7 @@ The command runs Python repository-integrity validation, the dependency-free Nod | Performance criterion observation Job, cycle, staffing, current-recorded-time, and UTC date-boundary integrity | `bash tests/test_criterion_observation_scope_postgres.sh` against PostgreSQL 16 in Foundation CI | | 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` | | 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 22a4178fe..8b21cb5d4 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -14,6 +14,7 @@ | Evidence-backed human selection decisions | Talent Acquisition | `decision_evidence_set`, `selection_decision_evidence`, `selection_decision` | database-owned SHA-256 sealing, non-empty evidence, drift/reuse rejection, OpenAPI human-confirmation tests | ADR-0001 | implemented_on_active_pr | | Governed candidate-to-worker conversion | Talent Acquisition / People core | `candidate_worker_conversion_record` with candidate, person, employment, selection decision, audit event and outbox evidence | PostgreSQL exact hire/evidence/audit-envelope binding, correction provenance, tenant RLS, legacy-write rejection and bitemporal history contract | ADR-0001, ADR-0003, ADR-0006 | implemented_on_protected_main | | GET-only People API | People API / purpose-bound read boundary | `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}`, `read_worker_people_record()`, `PostgresPeopleReadPort` | People API HTTP and PostgreSQL read contracts with exact 100% owned statement/branch coverage; current conversion lineage; no mutation writes | ADR-0002, ADR-0008 | implemented_on_protected_main | +| 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 | | 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/UML.md b/docs/UML.md index efc8b23d6..476eaa773 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -46,6 +46,32 @@ flowchart LR The cluster is physically shared in the initial modular deployment. Each bounded context has a separate schema and role; direct reads of another context's application tables are prohibited. +## Employment-history read sequence + +```mermaid +sequenceDiagram + actor HRUser as Authorized HR user + participant Gateway + participant PeopleHTTP as EmploymentHistoryAsgiApp + participant Auth as Keyverse authentication + participant Service as Employment-history service + participant Store as Injected read port + + HRUser->>Gateway: GET Person Employment history(tenant, known_at, purpose, fields) + Gateway->>PeopleHTTP: Forward one versioned request + PeopleHTTP->>PeopleHTTP: Validate route/query before authentication + PeopleHTTP->>Auth: Authenticate one Bearer credential + Auth-->>PeopleHTTP: Principal and operation scope + PeopleHTTP->>Service: Authorize exact target and requested fields + Service->>Store: Read tenant/Person history at known_at + Store-->>Service: Bitemporal Employment versions + Service-->>PeopleHTTP: Authorized entries only + PeopleHTTP-->>HRUser: No-store JSON response or safe error +``` + +The route is read-only and does not query another service's application tables, +mutate Employment truth, or make an employment decision. + ## Selection decision sequence ```mermaid @@ -119,4 +145,4 @@ sequenceDiagram PeopleCore->>Audit: Persist assignment, audit/outbox, and idempotency binding PeopleCore-->>Gateway: assignment_record Location Gateway-->>HROps: Review the roster, then approve or correct -``` \ No newline at end of file +``` diff --git a/docs/adr/0155-employment-history-http-read.md b/docs/adr/0155-employment-history-http-read.md new file mode 100644 index 000000000..67c7250c3 --- /dev/null +++ b/docs/adr/0155-employment-history-http-read.md @@ -0,0 +1,63 @@ +# ADR 0155: Expose governed Employment history through a read-only HTTP boundary + +- **Status:** Proposed on active stacked PR #155; not protected-main truth until integrated +- **Date:** 2026-08-30 +- **Owners:** Orgmetra People API / customer read boundary +- **Extends:** ADR 0008 (purpose-bound PII authorization), ADR 0149 (Employment-history read) + +## Context + +PR #149 defines the purpose-bound Employment-history use case, but it does not +expose a customer-callable transport route. Deployments need one stable HTTP +boundary that preserves the same tenant, Person, purpose, field, bitemporal, +and no-disclosure controls without adding Employment mutation or employment- +decision authority. + +## Decision + +Add `EmploymentHistoryAsgiApp` with this route: + +```text +GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history + ?known_at=YYYY-MM-DDTHH:MM:SSZ + &purpose=employee_profile_review + &fields=effective_from,employment_status_code +``` + +The boundary validates operational UUIDs, exact required query keys, ASCII +query syntax, a UTC RFC 3339 `known_at` ending in `Z`, lower snake-case purpose +and fields, and duplicate-field/parameter rejection before authentication. It +reuses the existing People ASGI JSON transport and authorization-header parser, +authenticates exactly one Bearer credential, then delegates to +`read_employment_history()`. The operation declares +`orgmetra.people.employment_history.read`, returns only authorized fields, uses +`Cache-Control: no-store` and `Vary: Authorization`, and maps malformed input, +authentication, authorization, integrity, and unexpected failures to the +published client-safe error envelope. + +OpenAPI publishes the route, query/path parameters, Employment-history response, +scope, and 400/401/403/409 responses. The dedicated workflow checks the exact +PR head, compiles the service, and runs the complete People suite at 100% +statement and branch coverage. + +## Consequences + +- Customers receive one stable, read-only Employment-history boundary. +- Existing Employment-history service ownership remains responsible for + purpose-bound authorization, bitemporal validation, and persistence access. +- Error support references are opaque and safe for customer correlation; the + route does not expose backend exception details. +- The route intentionally does not add pagination, export, writes, + cross-service joins, or high-impact employment decisions; each requires a + separate contract. + +## Verification + +The test-only child head `6c2d6b89` fails during collection while the HTTP +adapter module is absent. The implementation retains that test-first chain and +must remain a Draft stacked PR until independent review and all protected +central gates are authoritative. + +RFC 3339, OpenAPI 3.2.0, NIST zero-trust authorization guidance, and +PostgreSQL temporal/read-boundary guidance inform this transport decision. They +are defense-in-depth references, not certification or merge evidence. diff --git a/docs/adr/README.md b/docs/adr/README.md index 099a21139..cac94e4e3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,3 +16,4 @@ | [0012](0012-governed-migration-handoff.md) | Governed migration handoff | Accepted on active implementation branch | | [0013](0013-governed-requisition-review-packet.md) | Governed requisition review packet | Accepted on active implementation branch | | [0014](0014-job-analysis-snapshot-persistence.md) | Persist governed job-analysis snapshots | Accepted on active implementation branch | +| [0155](0155-employment-history-http-read.md) | Expose governed Employment history through a read-only HTTP boundary | Proposed on active stacked PR #155 | diff --git a/docs/doctoring/employment-history-http-read-references.md b/docs/doctoring/employment-history-http-read-references.md new file mode 100644 index 000000000..8317ce2a6 --- /dev/null +++ b/docs/doctoring/employment-history-http-read-references.md @@ -0,0 +1,35 @@ +# Employment-history HTTP read references + +**Scope:** Standards basis for active PR #155. This file does not claim certification or protected-main integration. + +## APA 7 references + +Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). RFC Editor. https://doi.org/10.17487/RFC3339 + +National Institute of Standards and Technology. (2020). *Zero trust architecture* (NIST Special Publication 800-207). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-207 + +National Institute of Standards and Technology. (2023). *A zero trust architecture model for access control in cloud-native applications in multi-cloud environments* (NIST Special Publication 800-207A). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-207A + +OpenAPI Initiative. (2025). *OpenAPI Specification v3.2.0*. https://spec.openapis.org/oas/v3.2.0 + +PostgreSQL Global Development Group. (2025). *PostgreSQL 18 documentation: Date/time types*. https://www.postgresql.org/docs/18/datatype-datetime.html + +## Decision relevance + +RFC 3339 constrains the customer-visible `known_at` representation to an +unambiguous UTC instant. OpenAPI defines the published operation, parameter, +response, and error-envelope contract. NIST zero-trust guidance informs +explicit identity/resource authorization at the HTTP boundary. PostgreSQL +date/time semantics support the repository's separation of business dates from +timezone-aware system-recorded instants; the service remains responsible for +the complete bitemporal contract. + +These references constrain the transport adapter only. They do not authorize +scope expansion into compensation, performance, candidate, credential, or +automated employment-decision data. + +## Review date + +Rechecked as current primary references on 2026-08-30. Re-review if the +published OpenAPI major version, supported PostgreSQL major version, or cited +NIST authorization guidance changes. diff --git a/docs/traceability/employment-history-http-read.md b/docs/traceability/employment-history-http-read.md new file mode 100644 index 000000000..494e4c592 --- /dev/null +++ b/docs/traceability/employment-history-http-read.md @@ -0,0 +1,45 @@ +# Employment-history HTTP read traceability + +**Lifecycle status:** Active stacked PR #155 only. This document does not claim protected-`develop` integration. + +## Buyer problem + +PR #149 defines an authorized Employment-history read, but a customer still +needs one stable HTTP boundary to request that history without deployment- +specific parsing, authentication, or serialization code widening the data +surface. + +## Requirement-to-evidence matrix + +| Requirement | Production boundary | Regression | +| --- | --- | --- | +| Validate before protected work | `EmploymentHistoryAsgiApp` parses route, query, UUIDs, UTC cutoff, purpose, and fields before authentication | malformed input cases prove no authenticator or read-port call | +| Authenticate one bearer credential | existing `_authorization_header` and `extract_bearer_token` contracts | missing, duplicate, malformed, non-ASCII, and rejected credentials return 401 | +| Use least privilege and exact purpose | `orgmetra.people.employment_history.read` plus `read_employment_history()` policy binding | disallowed fields return 403 before the port is called | +| Preserve bitemporal scope | `known_at` is an exact UTC system-recorded cutoff passed to the Employment-history service | call capture and service bitemporal tests | +| Minimize the response | `resource_reference` plus authorized `entries[].fields` only | successful and empty-result response assertions; no Person/Position/Assignment joins | +| Fail closed without disclosure | stable 400/401/403/409/500 client-safe envelopes and opaque support reference | integrity and secret-bearing backend failures assert no internal details | +| Publish the same customer contract | OpenAPI route, parameters, response schema, scope, and responses | Python/Node structural OpenAPI mutation tests | +| Keep evidence on the exact candidate | dedicated workflow checks PR head and complete People suite | compile, exact 100% statement/branch coverage, and clean checkout | + +## Test-first chain + +1. **Contract-only child head:** `6c2d6b89` adds HTTP regressions while `orgmetra_people_api.employment_history_http` is absent. +2. **Expected RED:** focused collection fails with `ModuleNotFoundError` at that owning module boundary; this is distinct from a missing dependency-path invocation. +3. **Implementation:** add the smallest separate ASGI adapter, package-root export, OpenAPI contract, and dedicated quality workflow. +4. **Verification:** run the full People API suite with exact statement and branch coverage, repository validation, actionlint, CodeGraph synchronization, and current-head hosted checks. + +## Security and data boundary + +The route reads only authorized Employment-version fields and the already- +governed Person/Employment lineage. It does not join Position, Assignment, +compensation, candidate, performance, credential, prompt, or model-output data. +It performs no write, audit/outbox mutation, or high-impact employment decision. + +## Out of scope + +- Pagination or export workflows. +- Employment correction or mutation workflows. +- Cross-service application-database queries. +- Browser UI, Storybook, or Figma work; this slice is a transport contract. +- Release, tag, publication, or protected-default-branch authority. diff --git a/manifest.json b/manifest.json index 97f2bab14..9a7e571b0 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1 @@ -{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"32cc4ef78d1eca557fa01731026840be01211a043eb0ada552e4e6cb9eace353","bytes":17295,"lines":76},{"path":"CLAUDE.md","sha256":"add33884f466d324e20875388d103de41c6e062938a6e98727dc83a87ffe976f","bytes":1229,"lines":20},{"path":"LICENSE","sha256":"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30","bytes":11358,"lines":202},{"path":"NOTICE","sha256":"34b4618e946bdd8d33407d6ac5279f0a0388f5e7c8f79d2e7d8c3c47d0266042","bytes":305,"lines":4},{"path":"README.md","sha256":"1a9fc400d26d8137ae5911488794a6d3fa915957c95f27b36a48cef0fdf823c6","bytes":3785,"lines":81},{"path":"database/migrations/0001_foundation_schema.sql","sha256":"ce2ae52fc66b2f99597ea5285df82c66f90caa46174fef4930d68a8b6177d0dd","bytes":38747,"lines":916},{"path":"database/migrations/0002_sealed_evidence_digest.sql","sha256":"93d659ca8e0e9293a83d5422d043be7b1022c5470a5b22670aa3416fa334a04c","bytes":6649,"lines":202},{"path":"database/migrations/0003_audit_outbox_persistence.sql","sha256":"2aa7bbb8220923ec584537c0cd46f0cba2b692d69d431f097b7df6db75235bfc","bytes":15417,"lines":423},{"path":"database/migrations/0004_outbox_delivery_claim.sql","sha256":"d4504acf7d58528a2a8f4f03d1584b868c8d3ba9046a007b9c2e7cfef993b2ef","bytes":9451,"lines":234},{"path":"database/migrations/0005_outbox_delivery_finalization.sql","sha256":"b7e8790595b288f752d6ef5cc6cbfe4e1b6712248f5b7a3a25fa60016b6a4961","bytes":6125,"lines":170},{"path":"database/migrations/0006_outbox_delivery_dead_letter.sql","sha256":"c1fb91cdf98169fd6684984e86cb0a14fa19c8f1226028d2346a2a069df2b3c7","bytes":24919,"lines":628},{"path":"database/migrations/0007_outbox_retry_exhaustion.sql","sha256":"812f50d70ca5929c7eba964d34a208aedee660d11cc7ffc09d67688c4737e0d5","bytes":19081,"lines":476},{"path":"database/migrations/0008_audit_outbox_review_hardening.sql","sha256":"c3713a12db9d00fdc10005df1f86c07965e9555eefad78ca67e994537a739d9b","bytes":17562,"lines":448},{"path":"database/migrations/0009_candidate_worker_conversion_governance.sql","sha256":"4030666629a6b8deb383b8337ead4f09d6a945969313def2577a38f31f06cda9","bytes":11537,"lines":281},{"path":"database/migrations/0010_validity_study_case_integrity.sql","sha256":"3f594810ac9e1a6747a2bb4838e5ce65b921cb6e3d36fcdc3ff08b4a7579ebd1","bytes":11979,"lines":313},{"path":"database/migrations/0011_criterion_observation_scope.sql","sha256":"f9fe7c35f1ee7b167e1c2ba75a50a84febda9a6ccf8123b4f5726f51968694f9","bytes":7444,"lines":165},{"path":"database/migrations/0012_people_mutation_idempotency.sql","sha256":"52dbbb9ec7f9be5291593ba88f228d7fffd736dcb99547a08c1d6cad076afb69","bytes":3162,"lines":76},{"path":"database/migrations/0013_job_analysis_snapshot.sql","sha256":"b6553a5a4c94c4aa9f341a474e13bbe34db63044eda2446b3ebee178995977ee","bytes":12713,"lines":260},{"path":"docs/API_CONTRACT.md","sha256":"63533dff785da62b89e585d742a158e2aeb05913644f2bf9fb6486f281c2e589","bytes":4555,"lines":76},{"path":"docs/DATA_MODEL.md","sha256":"6ad29731ae7ee7aa5bf3a2d0bfef88894a35a2550edb2be3244d6f143d76444a","bytes":13366,"lines":85},{"path":"docs/ERD.md","sha256":"546001aa85c4fe020e0c39d881dc860daf7f69090596666fdf9092487b0725fe","bytes":6964,"lines":70},{"path":"docs/OPERABILITY.md","sha256":"82b2d3e70cec371ef35e9e0f982ac40fef84351976bc04b863b81d27023d5a62","bytes":11189,"lines":71},{"path":"docs/PRD.md","sha256":"3ad85ae633cce0fc7a93af39b21d7a7c70bb2efa786da6b12f3c5327906e34f1","bytes":5490,"lines":111},{"path":"docs/SECURITY.md","sha256":"01918512d8882060e9cff0c4aa8206e0eccbdfb61cfd7f829331123c7a9fe6ac","bytes":11185,"lines":64},{"path":"docs/STORYBOARD.md","sha256":"6e4ffb0eb03a80343f50d363ffc43b34da9348a44232dd947a9ff416ea92a3d2","bytes":1342,"lines":28},{"path":"docs/STORYBOOK.md","sha256":"82f79029b3c2b7a45393bad5ba8fabe61014d4b6149c7d4e73f70ba447f885e9","bytes":1389,"lines":50},{"path":"docs/TEST_STRATEGY.md","sha256":"d0a0bc3b54ed0fc7973747987f1afb117d6144c390b51ed9370eb571972a33f8","bytes":16534,"lines":135},{"path":"docs/THREAT_MODEL.md","sha256":"f314f375c2e41252536de224c7bc7e4a10ab8f340cb86642724e7399e32f4252","bytes":6736,"lines":23},{"path":"docs/TRACEABILITY.md","sha256":"dbf6fd91375ea28e05456d2a0c9ba629506cbac6f52f5dfda61ae68db2395f7e","bytes":11462,"lines":40},{"path":"docs/TRD.md","sha256":"23697d88a4882698e1a2782b7da3f2ccd0d3cd2d6d1bffe89b6597dc16851077","bytes":9064,"lines":101},{"path":"docs/UML.md","sha256":"fe67c37aa88e5814ceb2db7e8f7d8d85ca27a994802efbb7c75164b387adf0a9","bytes":5528,"lines":122},{"path":"docs/USER_STORIES.md","sha256":"5535b39d8c71a36c81f78e2d6dbd90a2d32e6541790f0d28f6dd4baf3ea7b45f","bytes":2670,"lines":37},{"path":"docs/WIREFRAMES.md","sha256":"b03aa6419aeaf5d42a5698c4d43a434c1633b7ac6fd0b0bd0cda979077adc56e","bytes":2005,"lines":77},{"path":"docs/adr/0001-orgmetra-authoritative-hris-record.md","sha256":"0f8055b73c63d3130321415ad53233588ff952aabd1a88952b39c71747253572","bytes":6108,"lines":53},{"path":"docs/adr/0002-federated-cwl-integration-boundaries.md","sha256":"b77165f2aacfa6f4fde994baf77d5879c6da3e8dae4fd2db0ed912d60ae9b3b2","bytes":4072,"lines":44},{"path":"docs/adr/0003-bitemporal-hris-data-contract.md","sha256":"d7f2660616622c1a7994b28aa66d99d13836bcf755735595f9609a41282ab799","bytes":4453,"lines":47},{"path":"docs/adr/0004-employment-position-version-and-assignment-binding.md","sha256":"fee89e700414abe0b1cffec2acc687e5e014634db8f5ef9e8a92abba5c3cf182","bytes":1872,"lines":30},{"path":"docs/adr/0005-exclusive-employment-and-staffable-seats.md","sha256":"10f0eb409f4fa32d2c5bed2d583d8b43be8e61b5cbef0e927e5bebb5f5c8f85b","bytes":2091,"lines":34},{"path":"docs/adr/0006-governed-audit-outbox-envelope.md","sha256":"827298ddd997b47f78a89e89911ad8ea72e517b7714303637f0329b8cb52cabd","bytes":14100,"lines":66},{"path":"docs/adr/0007-governed-job-analysis-evidence.md","sha256":"953c6d2b9864a78b461b576092ec3f198f0b76709eaaaf7d0ed0182f95182c52","bytes":5653,"lines":57},{"path":"docs/adr/0008-purpose-bound-pii-authorization.md","sha256":"c5157d3bc58f3d8d29e03104dd15eb2911cc1bb66e2c92a935b26d7164648dc7","bytes":5988,"lines":55},{"path":"docs/adr/0009-performance-criterion-observation-scope.md","sha256":"1ac10bb2747b0a5b4d62f627825cfd7f978f3fa88d7575bffc23d56371240a64","bytes":7057,"lines":57},{"path":"docs/adr/0010-naruon-calendar-intent-boundary.md","sha256":"3e1050a964cc4ed76a1a0cf1e699ae5080acf8c9336f0decdd6d5229359db3c9","bytes":3917,"lines":35},{"path":"docs/adr/0011-bitemporal-workforce-composition.md","sha256":"1656ef8b57c836ef7936a8e9cb6a824681eb7563157a1ab0a29deb25849a457b","bytes":5568,"lines":53},{"path":"docs/adr/0012-governed-migration-handoff.md","sha256":"713855d670001d3964ecb36cc653830502fb1d82a58b9e39f564b6992dd2bd80","bytes":5965,"lines":59},{"path":"docs/adr/0013-governed-requisition-review-packet.md","sha256":"70bf2cbdf903a8793d6d8bc116a08331931090118341f42010236e09c6cc1802","bytes":4693,"lines":46},{"path":"docs/adr/0014-job-analysis-snapshot-persistence.md","sha256":"a7ab6fee50aaa63f7f407516a4cb39885faeb0fc6e5035ee8fc352ed73430105","bytes":5365,"lines":49},{"path":"docs/adr/README.md","sha256":"f3b3b5ed3b3b31a40a0a3696abf0065e3c25879b6be50077f38ffae742b9d002","bytes":1838,"lines":18},{"path":"docs/doctoring/REFERENCES.md","sha256":"929f7ee36df16279f028f726fcf039982180deb377746fe3804f3c0d090778d5","bytes":6352,"lines":69},{"path":"docs/superpowers/plans/2026-08-15-orgmetra-foundation-implementation-plan.md","sha256":"b64f21abb19373e780db8b9e64deb8ba9a6219ccf9625a651f25407b8691fcbd","bytes":8227,"lines":226},{"path":"docs/superpowers/specs/2026-08-15-orgmetra-foundation-design.md","sha256":"4a0e1a7943e40d12bd3082db3757045b4085e5a089fea7bc0d8a1565ffcbcf1d","bytes":6237,"lines":187},{"path":"package.json","sha256":"59ae9e3e67c3fba9320cb18439692395cdfd16ae5c24e3c4cf30d77d63ebabb5","bytes":388,"lines":9},{"path":"packages/hris-kernel/src/orgmetra_hris_kernel/audit.py","sha256":"3e5b7190cf857dc8c1fc7e898cef303060f34aabee6c27a9034d4d9650e33190","bytes":7707,"lines":160},{"path":"packages/hris-kernel/tests/test_audit_outbox.py","sha256":"5928dd7b97fe38d6b7472ce62966437e339058a59c3b301a93a7b5c05432b40c","bytes":7556,"lines":200},{"path":"schemas/openapi.yaml","sha256":"09c1e43486779198574fe31b8bcabbd1c1f74beec7bf86245ae578061619838f","bytes":29503,"lines":1020},{"path":"scripts/foundation-contract-core.mjs","sha256":"595e8381dbd62e97093b11eef818af5f04d6473ac592d57e3985ffbc2210d445","bytes":28173,"lines":689},{"path":"scripts/foundation-contract.mjs","sha256":"5242dcdbe0935775edf074462c82600e9bc4927d9fdc50c47727af915fd4b23a","bytes":218,"lines":6},{"path":"tests/dispatcher-inventory.test.mjs","sha256":"09f5e64410e6b7a26bf8d6ce61c50b737da2ea85d955f91eba63aa21f1537261","bytes":1597,"lines":34},{"path":"tests/foundation-contract.test.mjs","sha256":"960306fd7cda7b982a52c4428a432d10a4f570430a5d39fb23aeca0b2ede0615","bytes":14860,"lines":386},{"path":"tests/openapi-contract.test.mjs","sha256":"80c1610ef1c189fa325e55389501e0e51531ddf61ee335bb94d9cb3aa55a9fdc","bytes":6438,"lines":195},{"path":"tests/test_audit_outbox_hardening_postgres.sh","sha256":"518ba2f37ba6292943e5abe22c2599452b2f031a42e453b2493aedf8714421a0","bytes":13396,"lines":333},{"path":"tests/test_audit_outbox_postgres.sh","sha256":"e57a04920a0ba97fa6a06752d15ea150016ab8d44099e998c5c4f4067592b4d2","bytes":13443,"lines":357},{"path":"tests/test_bitemporal_postgres.sh","sha256":"7684b8c2ff52c044c081135515bd5aabbfd00e2daad0d471b0868701af2df6cc","bytes":8209,"lines":230},{"path":"tests/test_candidate_worker_conversion_postgres.sh","sha256":"681cb74d6cfa859ed92c6c2439881ea20c430ef8df94ec662e2807761a377f90","bytes":14673,"lines":344},{"path":"tests/test_criterion_observation_scope_postgres.sh","sha256":"0ee9539ee57f840c27d08009f7868cdc8662669df78a01dbc8be39216b8f1a3d","bytes":17811,"lines":469},{"path":"tests/test_evidence_sealing_postgres.sh","sha256":"57d16b632a0c60ffdcb4842ceb1cfe25d19c54cefeeefb622ff4fa6e83441ad7","bytes":11349,"lines":370},{"path":"tests/test_job_analysis_snapshot_postgres.sh","sha256":"ca9c323a1dd68cfc520277efbbb7495e37fb3ca027890928c8624e5b4f57403f","bytes":13542,"lines":296},{"path":"tests/test_operational_uuid_postgres.sh","sha256":"7378f98f0d4b3000e8ea641d8701f1540dbad71410b3637d81d799969e0f6ff7","bytes":3346,"lines":101},{"path":"tests/test_outbox_claim_postgres.sh","sha256":"1027806d436ebfe34e108c25b6a4001f43b9550f1d70057c6c0d7974323b0c9b","bytes":14817,"lines":429},{"path":"tests/test_outbox_dead_letter_postgres.sh","sha256":"0d728d578e64252e6079f2d141ddaa7fa9cfbf9784e625832273596d69a6e13d","bytes":14008,"lines":377},{"path":"tests/test_people_mutation_idempotency_postgres.sh","sha256":"3f57e12f80bd1b034c9aac54b669d8530106e3e26b3795689671fb53807b3cd5","bytes":16191,"lines":381},{"path":"tests/test_tenant_isolation_postgres.sh","sha256":"dd649435ef8ab9e57f0609c101917e36656a6d40d63de9bcdbdac23d764f6c3a","bytes":15134,"lines":388},{"path":"tests/test_validity_study_case_postgres.sh","sha256":"0070ad58300323c7f9900c5645e0df3106b36ccd245ae686e982c2fd6fa4dc02","bytes":14708,"lines":301},{"path":"tests/validate_repository.py","sha256":"918cf92fd18d81572e9bd5f5daa7f033c32731e2e13f0d00661d1c1de30b12a9","bytes":27291,"lines":638}]} +{"package":"orgmetra-foundation-pack","version":"0.1.0","generated_for_branch":"feat/audit-outbox-envelope","files":[{"path":".github/workflows/foundation-ci.yml","sha256":"12686a3bbd6445e6fdb202b4137dae118ddeeab1efb0c7f18ea6c8fa19d62537","bytes":4379,"lines":123},{"path":".github/workflows/job-analysis-api-quality.yml","sha256":"352dc78931dd94afea3e88912d38dcc4b562a004112f199f3d7a12d22b6d637a","bytes":4159,"lines":105},{"path":".gitignore","sha256":"145fda644f5209fa1fb3e3b40c9af9258bfac6d1a634bba2520fd08fe6d77a21","bytes":375,"lines":37},{"path":"AGENTS.md","sha256":"28f7b7bc010a7739cfdc3e793fb5d39a0e74b842ea9c190e9a251e2d0cbc3a16","bytes":2246,"lines":34},{"path":"ARCHITECTURE.md","sha256":"52d68786f7359c1a50d804996021e4c70e90accd2fff6f1a27c91de1dd8df850","bytes":7864,"lines":107},{"path":"CHANGELOG.md","sha256":"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}]} diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 0fd397e92..45a9ff9f1 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -344,6 +344,64 @@ paths: $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/SnapshotNotFound' + /tenants/{tenant_record_id}/people/{person_record_id}/employment-history: + get: + operationId: readEmploymentHistory + summary: Read purpose-bound bitemporal Employment history for one Person + tags: + - people-core + security: + - keyverse_oidc: + - orgmetra.people.employment_history.read + parameters: + - name: tenant_record_id + in: path + required: true + schema: + type: string + format: uuid + - name: person_record_id + in: path + required: true + schema: + type: string + format: uuid + - name: known_at + in: query + required: true + description: System-time knowledge cutoff for the half-open recorded interval. + schema: + type: string + format: date-time + - name: purpose + in: query + required: true + description: Business purpose evaluated by the purpose-bound authorization policy. + schema: + type: string + pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + - name: fields + in: query + required: true + description: Comma-separated, explicitly requested Employment-history fields. + schema: + type: string + pattern: '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*(,[a-z][a-z0-9]*(?:_[a-z0-9]+)*)*$' + responses: + '200': + description: Authorized Employment-history entries ordered by effective date. + content: + application/json: + schema: + $ref: '#/components/schemas/EmploymentHistoryResponse' + '400': + $ref: '#/components/responses/InvalidCommand' + '401': + $ref: '#/components/responses/Unauthenticated' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/EmploymentHistoryIntegrityConflict' components: securitySchemes: keyverse_oidc: @@ -669,6 +727,32 @@ components: assignment_record_id: type: string format: uuid + EmploymentHistoryResponse: + type: object + additionalProperties: false + required: + - resource_reference + - entries + properties: + resource_reference: + type: string + pattern: '^person_employment_history:[0-9a-f]{32}$' + entries: + type: array + items: + $ref: '#/components/schemas/EmploymentHistoryEntry' + EmploymentHistoryEntry: + type: object + additionalProperties: false + required: + - fields + properties: + fields: + type: object + additionalProperties: + oneOf: + - type: string + - type: 'null' JobAnalysisEvidenceSource: type: object additionalProperties: false @@ -1012,6 +1096,12 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + EmploymentHistoryIntegrityConflict: + description: The Employment-history evidence failed its trusted bitemporal integrity checks. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' UnsupportedMediaType: description: The job-analysis POST body is missing application/json media type. content: diff --git a/scripts/foundation-contract-core.mjs b/scripts/foundation-contract-core.mjs index 1e9fb267c..13eb7954c 100644 --- a/scripts/foundation-contract-core.mjs +++ b/scripts/foundation-contract-core.mjs @@ -539,6 +539,24 @@ export function validateOpenApiContract(openapiText) { requireWithin(errors, 'readJobAnalysisSnapshot', jobAnalysisRead, ' - orgmetra.job_architecture.read', 'least-privilege read scope'); } + const employmentHistoryRead = extractYamlBlock( + openapiText, + ' /tenants/{tenant_record_id}/people/{person_record_id}/employment-history:' + ); + if (!employmentHistoryRead) { + errors.push('readEmploymentHistory: path block is missing'); + } else { + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, 'operationId: readEmploymentHistory', 'operationId'); + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, ' - orgmetra.people.employment_history.read', 'least-privilege read scope'); + for (const parameterName of ['tenant_record_id', 'person_record_id', 'known_at', 'purpose', 'fields']) { + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, ` - name: ${parameterName}`, `required ${parameterName} parameter`); + } + for (const responseCode of [" '200':", " '400':", " '401':", " '403':", " '409':"]) { + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, responseCode, `response ${responseCode.trim()}`); + } + requireWithin(errors, 'readEmploymentHistory', employmentHistoryRead, "$ref: '#/components/schemas/EmploymentHistoryResponse'", 'response schema'); + } + const jobCommand = extractYamlBlock(openapiText, ' CreateJobProfileCommand:'); if (!jobCommand) { errors.push('CreateJobProfileCommand: schema block is missing'); diff --git a/services/people-api/README.md b/services/people-api/README.md index 548a83446..a01a0eabc 100644 --- a/services/people-api/README.md +++ b/services/people-api/README.md @@ -10,6 +10,8 @@ The service exposes a governed hire-to-employment read contract. `read_worker_pe `PeopleAsgiApp` exposes that governed read use case as a dependency-light ASGI route: `GET /v1/tenants/{tenant_record_id}/people/{person_record_id}?effective_on=YYYY-MM-DD&purpose=people_read&fields=...`. It validates the exact route and query shape before authentication, accepts exactly one ASCII Bearer credential, delegates authentication and purpose-bound authorization to injected contracts, and never reads protected worker values after a denied authorization decision. Successful responses contain only authorized fields; all HTTP responses use `Cache-Control: no-store` and `Vary: Authorization`. Authentication, authorization, missing-record, integrity-conflict, and unexpected-backend failures are mapped to stable non-disclosing responses with a useful next action, and bearer tokens are never returned in response text. +`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. + 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 8486d5552..1899ef30a 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -15,6 +15,7 @@ EmploymentHistoryRecord, read_employment_history, ) +from orgmetra_people_api.employment_history_http import EmploymentHistoryAsgiApp from orgmetra_people_api.hire import ( HireAcceptanceCommand, HireAcceptancePort, @@ -61,6 +62,7 @@ "AuthorizedEmploymentHistoryView", "AuthorizedWorkerPeopleView", "EmploymentHistoryIntegrityError", + "EmploymentHistoryAsgiApp", "EmploymentHistoryReadPort", "EmploymentHistoryRecord", "EmploymentMutationCommand", diff --git a/services/people-api/src/orgmetra_people_api/employment_history_http.py b/services/people-api/src/orgmetra_people_api/employment_history_http.py new file mode 100644 index 000000000..a62d82bc7 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/employment_history_http.py @@ -0,0 +1,283 @@ +"""Dependency-light ASGI route for governed Employment-history reads. + +The transport adapter owns request parsing and client-safe responses. The +Employment-history service remains responsible for purpose-bound authorization, +bitemporal integrity, and minimizing the fields returned to the caller. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import logging +import re +from secrets import token_urlsafe +from typing import Mapping +from urllib.parse import parse_qsl +from uuid import UUID + +from orgmetra_keyverse_adapter import AuthorizationDeniedError, PurposeBoundAccessPolicy + +from orgmetra_people_api.auth import ( + AuthenticationFailed, + TokenAuthenticator, + extract_bearer_token, +) +from orgmetra_people_api.employment_history import ( + EmploymentHistoryIntegrityError, + EmploymentHistoryReadPort, + read_employment_history, +) +from orgmetra_people_api.http import ( + AsgiReceive, + AsgiSend, + _authorization_header, + _send_json as _emit_json, +) + +_LOGGER = logging.getLogger(__name__) +_ROUTE_PREFIX = ("v1", "tenants") +_PURPOSE_PATTERN = re.compile(r"\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\Z", flags=re.ASCII) +_FIELD_PATTERN = re.compile(r"\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\Z", flags=re.ASCII) +_RFC3339_INSTANT_PATTERN = re.compile( + r"\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z\Z", + flags=re.ASCII, +) +_MAX_UUID_INT = (1 << 128) - 1 +_REQUIRED_QUERY_KEYS = frozenset({"known_at", "purpose", "fields"}) +_SUPPORT_REFERENCE_RANDOM_BYTES = 24 + + +class _InvalidHttpRequest(ValueError): + """Indicate malformed Employment-history route input that must fail closed.""" + + +@dataclass(frozen=True, slots=True) +class _ParsedEmploymentHistoryRequest: + """Hold validated path and query values for one Employment-history read.""" + + tenant_record_id: UUID + person_record_id: UUID + known_at: datetime + purpose_code: str + requested_fields: frozenset[str] + + +async def _send_error( + send: AsgiSend, + *, + status: int, + error_code: str, + message: str, + extra_headers: tuple[tuple[bytes, bytes], ...] = (), +) -> None: + """Emit a client-safe error envelope with an opaque support reference.""" + support_reference = f"err_{token_urlsafe(_SUPPORT_REFERENCE_RANDOM_BYTES)}" + _LOGGER.info( + "Employment-history request rejected", + extra={ + "error_code": error_code, + "http_status": status, + "support_reference": support_reference, + }, + ) + await _emit_json( + send, + status=status, + payload={ + "error": error_code, + "error_code": error_code, + "message": message, + "next_action": message, + "support_reference": support_reference, + }, + extra_headers=extra_headers, + ) + + +@dataclass(frozen=True, slots=True) +class EmploymentHistoryAsgiApp: + """Expose one tenant-scoped, read-only Employment-history route. + + Supported route:: + + GET /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history + ?known_at=YYYY-MM-DDTHH:MM:SSZ + &purpose=employee_profile_review + &fields=effective_from,employment_status_code + + The app contains no web-framework dependency and returns only the + purpose-authorized Employment-history fields from the governed service. + """ + + authenticator: TokenAuthenticator + policy: PurposeBoundAccessPolicy + read_port: EmploymentHistoryReadPort + + def __post_init__(self) -> None: + """Reject incomplete dependencies before serving protected data.""" + if not isinstance(self.authenticator, TokenAuthenticator): + raise TypeError("authenticator must implement TokenAuthenticator") + if not isinstance(self.policy, PurposeBoundAccessPolicy): + raise TypeError("policy must be a PurposeBoundAccessPolicy") + if not isinstance(self.read_port, EmploymentHistoryReadPort): + raise TypeError("read_port must implement EmploymentHistoryReadPort") + + async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send: AsgiSend) -> None: + """Serve one HTTP request without exposing bearer tokens or internals.""" + del receive + if scope.get("type") != "http": + raise ValueError("EmploymentHistoryAsgiApp accepts only HTTP ASGI scopes") + + if scope.get("method") != "GET": + await _send_error( + send, + status=405, + error_code="method_not_allowed", + message="Use GET for the governed Employment-history read route.", + extra_headers=((b"allow", b"GET"),), + ) + return + + path = scope.get("path") + if not isinstance(path, str) or not _looks_like_employment_history_route(path): + await _send_error( + send, + status=404, + error_code="route_not_found", + message="Use /v1/tenants/{tenant_record_id}/people/{person_record_id}/employment-history.", + ) + return + + try: + request = _parse_employment_history_request(path, scope.get("query_string", b"")) + except _InvalidHttpRequest: + await _send_error( + send, + status=400, + error_code="invalid_request", + message="Correct the tenant/Person IDs and required known_at, purpose, and fields query parameters, then retry.", + ) + return + + try: + bearer_token = extract_bearer_token(_authorization_header(scope)) + principal = await self.authenticator.authenticate(bearer_token) + except AuthenticationFailed: + await _send_error( + send, + status=401, + error_code="authentication_required", + message="Provide one valid Bearer credential and retry.", + extra_headers=((b"www-authenticate", b"Bearer"),), + ) + return + + try: + view = read_employment_history( + principal=principal, + tenant_record_id=request.tenant_record_id, + person_record_id=request.person_record_id, + known_at=request.known_at, + purpose_code=request.purpose_code, + requested_fields=request.requested_fields, + policy=self.policy, + read_port=self.read_port, + ) + except AuthorizationDeniedError: + await _send_error( + send, + status=403, + error_code="access_denied", + message="Request only fields and a purpose authorized for this exact Person Employment history.", + ) + return + except EmploymentHistoryIntegrityError: + await _send_error( + send, + status=409, + error_code="employment_history_integrity_conflict", + message="The Employment history cannot be returned safely; ask an Orgmetra operator to inspect the authoritative lineage.", + ) + return + except Exception: # noqa: BLE001 - HTTP boundary must fail closed without backend details. + await _send_error( + send, + status=500, + error_code="internal_error", + message="Retry later or contact an Orgmetra operator with non-secret request metadata; never include the bearer token.", + ) + return + + await _emit_json( + send, + status=200, + payload={ + "resource_reference": view.resource_reference, + "entries": [{"fields": dict(entry.field_values)} for entry in view.entries], + }, + ) + + +def _looks_like_employment_history_route(path: str) -> bool: + """Recognize the versioned Employment-history route before parsing IDs.""" + parts = path.strip("/").split("/") + return ( + len(parts) == 6 + and tuple(parts[:2]) == _ROUTE_PREFIX + and parts[3] == "people" + and parts[5] == "employment-history" + ) + + +def _parse_employment_history_request(path: str, raw_query: object) -> _ParsedEmploymentHistoryRequest: + """Validate all caller-controlled path/query values before authentication.""" + parts = path.strip("/").split("/") + try: + tenant_record_id = UUID(parts[2]) + person_record_id = UUID(parts[4]) + except (ValueError, IndexError) as error: + raise _InvalidHttpRequest("route IDs must be UUIDs") from error + if tenant_record_id.int in (0, _MAX_UUID_INT) or person_record_id.int in (0, _MAX_UUID_INT): + raise _InvalidHttpRequest("route IDs must be operational UUIDs") + + if not isinstance(raw_query, bytes): + raise _InvalidHttpRequest("query_string must be bytes") + try: + query_text = raw_query.decode("ascii") + pairs = parse_qsl(query_text, keep_blank_values=True, strict_parsing=True) + except (UnicodeDecodeError, ValueError) as error: + raise _InvalidHttpRequest("query string is malformed") from error + + query: dict[str, str] = {} + for key, value in pairs: + if key in query: + raise _InvalidHttpRequest("duplicate query parameter") + query[key] = value + if frozenset(query) != _REQUIRED_QUERY_KEYS: + raise _InvalidHttpRequest("query parameters are incomplete or unsupported") + + raw_known_at = query["known_at"] + if _RFC3339_INSTANT_PATTERN.fullmatch(raw_known_at) is None: + raise _InvalidHttpRequest("known_at must be a UTC RFC 3339 instant") + try: + known_at = datetime.fromisoformat(raw_known_at[:-1] + "+00:00") + except ValueError as error: + raise _InvalidHttpRequest("known_at must be a valid UTC RFC 3339 instant") from error + purpose_code = query["purpose"] + if _PURPOSE_PATTERN.fullmatch(purpose_code) is None: + raise _InvalidHttpRequest("purpose must be a lower snake-case code") + + raw_fields = query["fields"].split(",") + if any(_FIELD_PATTERN.fullmatch(field) is None for field in raw_fields): + raise _InvalidHttpRequest("fields must be explicit lower snake_case names") + if len(set(raw_fields)) != len(raw_fields): + raise _InvalidHttpRequest("fields must not repeat") + + return _ParsedEmploymentHistoryRequest( + tenant_record_id=tenant_record_id, + person_record_id=person_record_id, + known_at=known_at, + purpose_code=purpose_code, + requested_fields=frozenset(raw_fields), + ) diff --git a/services/people-api/tests/test_employment_history_http.py b/services/people-api/tests/test_employment_history_http.py new file mode 100644 index 000000000..e6504d65b --- /dev/null +++ b/services/people-api/tests/test_employment_history_http.py @@ -0,0 +1,354 @@ +"""Executable HTTP transport contracts for governed Employment-history reads.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +import json +import re +import unittest +from uuid import UUID + +from orgmetra_keyverse_adapter import PurposeBoundAccessPolicy +from orgmetra_people_api import ( + AuthenticatedPrincipal, + AuthenticationFailed, + EmploymentHistoryRecord, +) +from orgmetra_people_api.employment_history_http import EmploymentHistoryAsgiApp + +TENANT = UUID("0198a414-6000-7000-8000-000000000001") +PERSON = UUID("0198a414-6000-7000-8000-000000000010") +VERSION_A = UUID("0198a414-6000-7000-8000-000000000020") +VERSION_B = UUID("0198a414-6000-7000-8000-000000000021") +EMPLOYMENT_A = UUID("0198a414-6000-7000-8000-000000000030") +EMPLOYMENT_B = UUID("0198a414-6000-7000-8000-000000000031") +KNOWN_AT = datetime(2026, 8, 30, tzinfo=timezone.utc) +DEFAULT_QUERY = ( + b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&" + b"fields=effective_from,employment_status_code" +) +_SUPPORT_REFERENCE = re.compile(r"^err_[A-Za-z0-9_-]{20,80}$") + + +class FakeAuthenticator: + """Return one principal while recording the opaque bearer token.""" + + def __init__(self, principal: AuthenticatedPrincipal, *, error: Exception | None = None) -> None: + self.principal = principal + self.error = error + self.tokens: list[str] = [] + + async def authenticate(self, bearer_token: str) -> AuthenticatedPrincipal: + """Authenticate one token without logging or returning its value.""" + self.tokens.append(bearer_token) + if self.error is not None: + raise self.error + return self.principal + + +class FakeReadPort: + """Return configured Employment history and capture protected reads.""" + + def __init__(self, records: tuple[EmploymentHistoryRecord, ...]) -> None: + self.records = records + self.calls: list[tuple[UUID, UUID, datetime]] = [] + + def read_employment_history( + self, + *, + tenant_record_id: UUID, + person_record_id: UUID, + known_at: datetime, + ) -> tuple[EmploymentHistoryRecord, ...]: + """Return deterministic history for transport tests.""" + self.calls.append((tenant_record_id, person_record_id, known_at)) + return self.records + + +class ExplodingReadPort: + """Model an unexpected persistence failure without leaking its details.""" + + def read_employment_history( + self, + *, + tenant_record_id: UUID, + person_record_id: UUID, + known_at: datetime, + ) -> tuple[EmploymentHistoryRecord, ...]: + """Raise a secret-bearing error that must never reach the response body.""" + del tenant_record_id, person_record_id, known_at + raise RuntimeError("postgres password=do-not-leak") + + +def history_record( + *, + employment_record_id: UUID = EMPLOYMENT_A, + employment_record_version_id: UUID = VERSION_A, + effective_from: date = date(2026, 1, 1), + effective_to: date | None = date(2026, 7, 1), +) -> EmploymentHistoryRecord: + """Build one canonical Employment-history fixture.""" + return EmploymentHistoryRecord( + tenant_record_id=TENANT, + person_record_id=PERSON, + employment_record_id=employment_record_id, + employment_record_version_id=employment_record_version_id, + employment_status_code="active", + employment_concurrency_code="exclusive", + effective_from=effective_from, + effective_to=effective_to, + recorded_from=datetime(2026, 1, 1, tzinfo=timezone.utc), + recorded_to=None, + ) + + +class EmploymentHistoryHttpRouteTests(unittest.IsolatedAsyncioTestCase): + """Prove the route preserves authentication, authorization, and lineage controls.""" + + def setUp(self) -> None: + self.principal = AuthenticatedPrincipal( + tenant_record_id=TENANT, + actor_reference="keyverse:actor-1", + granted_scope_codes=frozenset({"orgmetra.people.employment_history.read"}), + ) + self.policy = PurposeBoundAccessPolicy( + tenant_record_id=TENANT, + policy_version_code="employment-history-http-v1", + resource_kind="person_employment_history", + purpose_code="employee_profile_review", + operation_code="read_record", + required_scope_code="orgmetra.people.employment_history.read", + permitted_fields=frozenset( + {"effective_from", "employment_status_code", "recorded_to"} + ), + ) + + def _app( + self, + *, + authenticator: object | None = None, + policy: object | None = None, + read_port: object | None = None, + ) -> EmploymentHistoryAsgiApp: + """Build the ASGI app with explicit injected boundaries.""" + return EmploymentHistoryAsgiApp( + authenticator=authenticator if authenticator is not None else FakeAuthenticator(self.principal), + policy=policy if policy is not None else self.policy, + read_port=read_port if read_port is not None else FakeReadPort((history_record(),)), + ) + + async def _request( + self, + app: EmploymentHistoryAsgiApp, + *, + method: str = "GET", + path: object | None = None, + query: object = DEFAULT_QUERY, + headers: object | None = None, + ) -> tuple[int, dict[bytes, bytes], dict[str, object]]: + scope = { + "type": "http", + "method": method, + "path": path + if path is not None + else f"/v1/tenants/{TENANT}/people/{PERSON}/employment-history", + "query_string": query, + "headers": headers if headers is not None else [(b"authorization", b"Bearer opaque-token")], + } + messages: list[dict[str, object]] = [] + + async def receive() -> dict[str, object]: + """Supply an empty ASGI request body.""" + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + """Capture the ASGI response messages.""" + messages.append(message) + + await app(scope, receive, send) + start, body = messages + response_headers = dict(start["headers"]) + return int(start["status"]), response_headers, json.loads(bytes(body["body"])) + + def test_constructor_rejects_missing_transport_dependencies(self) -> None: + """Keep authentication, policy, and persistence dependencies explicit.""" + with self.assertRaisesRegex(TypeError, "authenticator"): + self._app(authenticator=object()) + with self.assertRaisesRegex(TypeError, "policy"): + self._app(policy=object()) + with self.assertRaisesRegex(TypeError, "read_port"): + self._app(read_port=object()) + + async def test_get_history_returns_only_authorized_fields_with_private_cache_controls(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + + status, headers, payload = await self._request(app) + + self.assertEqual(status, 200) + self.assertEqual(headers[b"content-type"], b"application/json") + self.assertEqual(headers[b"cache-control"], b"no-store") + self.assertEqual(headers[b"vary"], b"Authorization") + self.assertEqual(payload["resource_reference"], f"person_employment_history:{PERSON.hex}") + self.assertEqual( + payload["entries"], + [{"fields": {"effective_from": "2026-01-01", "employment_status_code": "active"}}], + ) + self.assertEqual(authenticator.tokens, ["opaque-token"]) + self.assertEqual(port.calls, [(TENANT, PERSON, KNOWN_AT)]) + + async def test_empty_history_is_a_successful_empty_collection(self) -> None: + status, _, payload = await self._request(self._app(read_port=FakeReadPort(()))) + + self.assertEqual((status, payload["entries"]), (200, [])) + + async def test_malformed_request_fails_before_authentication_or_protected_read(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + zero_tenant_path = f"/v1/tenants/{UUID(int=0)}/people/{PERSON}/employment-history" + max_person_path = f"/v1/tenants/{TENANT}/people/{UUID(int=(1 << 128) - 1)}/employment-history" + cases = ( + {"path": "/v1/tenants/not-a-uuid/people/not-a-uuid/employment-history"}, + {"path": zero_tenant_path}, + {"path": max_person_path}, + {"query": "known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"\xff"}, + {"query": b"bogus"}, + {"query": b"purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&purpose=other&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00+00:00&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=not-a-time&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-02-30T00:00:00Z&purpose=employee_profile_review&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=EmployeeProfileReview&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=&fields=effective_from"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&fields="}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&fields=EffectiveFrom"}, + {"query": b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&fields=effective_from,effective_from"}, + ) + for case in cases: + with self.subTest(case=case): + status, _, payload = await self._request(app, **case) + self.assertEqual(status, 400) + self.assertEqual(payload["error_code"], "invalid_request") + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_wrong_path_and_method_return_transport_errors_without_authentication(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + + wrong_paths: tuple[object, ...] = ( + "/v1/unknown", + f"/v2/tenants/{TENANT}/people/{PERSON}/employment-history", + f"/v1/tenants/{TENANT}/people/{PERSON}/history", + f"/v1/tenants/{TENANT}/employments/{PERSON}/history", + 42, + ) + for path in wrong_paths: + with self.subTest(path=path): + status, _, payload = await self._request(app, path=path) + self.assertEqual((status, payload["error_code"]), (404, "route_not_found")) + status, headers, payload = await self._request(app, method="POST") + self.assertEqual((status, payload["error_code"]), (405, "method_not_allowed")) + self.assertEqual(headers[b"allow"], b"GET") + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_malformed_authorization_headers_are_unauthorized_without_authenticator_call(self) -> None: + authenticator = FakeAuthenticator(self.principal) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + header_cases: tuple[object, ...] = ( + [], + object(), + [(b"authorization", b"Bearer one"), (b"authorization", b"Bearer two")], + [(b"x-request-id", b"request-1")], + [(b"authorization",)], + [("authorization", "Bearer opaque-token")], + [(b"authorization", b"Bearer \xff")], + ) + for headers in header_cases: + with self.subTest(headers=headers): + status, response_headers, payload = await self._request(app, headers=headers) + self.assertEqual((status, payload["error_code"]), (401, "authentication_required")) + self.assertEqual(response_headers[b"www-authenticate"], b"Bearer") + self.assertEqual(authenticator.tokens, []) + self.assertEqual(port.calls, []) + + async def test_authenticator_rejection_is_unauthorized_without_protected_read(self) -> None: + authenticator = FakeAuthenticator(self.principal, error=AuthenticationFailed("expired")) + port = FakeReadPort((history_record(),)) + app = self._app(authenticator=authenticator, read_port=port) + + status, _, payload = await self._request(app) + + self.assertEqual((status, payload["error_code"]), (401, "authentication_required")) + self.assertEqual(authenticator.tokens, ["opaque-token"]) + self.assertEqual(port.calls, []) + + async def test_authorization_denial_does_not_read_employment_history(self) -> None: + port = FakeReadPort((history_record(),)) + app = self._app(read_port=port) + + status, _, payload = await self._request( + app, + query=b"known_at=2026-08-30T00:00:00Z&purpose=employee_profile_review&fields=employment_record_id", + ) + + self.assertEqual((status, payload["error_code"]), (403, "access_denied")) + self.assertEqual(port.calls, []) + + async def test_integrity_conflict_returns_client_safe_error(self) -> None: + bad_record = history_record() + bad_record = EmploymentHistoryRecord( + tenant_record_id=bad_record.tenant_record_id, + person_record_id=UUID("0198a414-6000-7000-8000-000000000011"), + employment_record_id=bad_record.employment_record_id, + employment_record_version_id=bad_record.employment_record_version_id, + employment_status_code=bad_record.employment_status_code, + employment_concurrency_code=bad_record.employment_concurrency_code, + effective_from=bad_record.effective_from, + effective_to=bad_record.effective_to, + recorded_from=bad_record.recorded_from, + recorded_to=bad_record.recorded_to, + ) + status, _, payload = await self._request(self._app(read_port=FakeReadPort((bad_record,)))) + + self.assertEqual((status, payload["error_code"]), (409, "employment_history_integrity_conflict")) + self.assertNotIn("employment_record_id", json.dumps(payload)) + + async def test_unexpected_failure_returns_generic_500_without_secret_details(self) -> None: + status, _, payload = await self._request(self._app(read_port=ExplodingReadPort())) + + self.assertEqual((status, payload["error_code"]), (500, "internal_error")) + self.assertNotIn("password", json.dumps(payload)) + + async def test_errors_use_the_published_client_safe_envelope(self) -> None: + status, _, payload = await self._request(self._app(), path="/v1/not-the-employment-history-route") + + self.assertEqual(status, 404) + self.assertEqual(payload["error"], payload["error_code"]) + self.assertEqual(payload["next_action"], payload["message"]) + self.assertRegex(payload["support_reference"], _SUPPORT_REFERENCE) + + async def test_non_http_scope_is_rejected_as_programming_error(self) -> None: + app = self._app(read_port=FakeReadPort(())) + + async def receive() -> dict[str, object]: + """Supply a lifespan message to prove it is never treated as HTTP.""" + return {"type": "lifespan.startup"} + + async def send(message: dict[str, object]) -> None: + """Reject any response for a non-HTTP scope.""" + del message + + with self.assertRaisesRegex(ValueError, "HTTP ASGI scopes"): + await app({"type": "lifespan"}, receive, send) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/openapi-contract.test.mjs b/tests/openapi-contract.test.mjs index 8e98078da..9689aaa82 100644 --- a/tests/openapi-contract.test.mjs +++ b/tests/openapi-contract.test.mjs @@ -166,6 +166,26 @@ for (const testCase of [ fragment: ' - confirmation_reference\n', occurrence: 5, expected: /CreateAssignmentRecordCommand.*confirmation/ + }, + { + name: 'readEmploymentHistory path', + fragment: ' /tenants/{tenant_record_id}/people/{person_record_id}/employment-history:\n', + expected: /readEmploymentHistory.*path block/ + }, + { + name: 'readEmploymentHistory scope', + fragment: ' - orgmetra.people.employment_history.read\n', + expected: /readEmploymentHistory.*scope/ + }, + { + name: 'readEmploymentHistory known_at parameter', + fragment: ' - name: known_at\n', + expected: /readEmploymentHistory.*known_at parameter/ + }, + { + name: 'readEmploymentHistory response schema', + fragment: " $ref: '#/components/schemas/EmploymentHistoryResponse'\n", + expected: /readEmploymentHistory.*response schema/ } ]) { test(`structural OpenAPI gate rejects missing ${testCase.name}`, () => { diff --git a/tests/validate_repository.py b/tests/validate_repository.py index fe0a329ff..93bff6fec 100644 --- a/tests/validate_repository.py +++ b/tests/validate_repository.py @@ -535,6 +535,45 @@ def _validate_openapi_contract() -> None: "least-privilege read scope", ) + employment_history_read_block = _yaml_block( + openapi, + " /tenants/{tenant_record_id}/people/{person_record_id}/employment-history:", + ) + if not employment_history_read_block: + _fail("readEmploymentHistory: path block is missing") + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + "operationId: readEmploymentHistory", + "operationId", + ) + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + " - orgmetra.people.employment_history.read", + "least-privilege read scope", + ) + for parameter_name in ("tenant_record_id", "person_record_id", "known_at", "purpose", "fields"): + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + f" - name: {parameter_name}", + f"required {parameter_name} parameter", + ) + for response in (" '200':", " '400':", " '401':", " '403':", " '409':"): + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + response, + f"response {response.strip()}", + ) + _require_in_block( + employment_history_read_block, + "readEmploymentHistory", + "$ref: '#/components/schemas/EmploymentHistoryResponse'", + "response schema", + ) + for schema_name in ( "CreateJobProfileCommand", "RecordSelectionDecisionCommand",