diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 31f3ff23e..b796cf5b1 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -6,6 +6,14 @@ - High-impact command audit append success: 99.99% within accepted maintenance windows. - Integration adapter error visibility: every failed outbound command produces an operator-safe event. +### People API liveness and readiness + +- `GET /health` is process-liveness evidence only. It must not call PostgreSQL, Keyverse, Naruon, or another external dependency; a healthy process returns `200` with the bounded body `{"status":"ok"}`. Orchestrators may use this route for restart decisions without turning a transient database outage into a restart loop. +- `GET /ready` is traffic-eligibility evidence for the People API's owned PostgreSQL dependency. The current adapter opens a read-only transaction, issues only `SET TRANSACTION READ ONLY` and `SELECT 1`, and does not query HR application tables or require tenant context. A successful check returns `200` with `{"status":"ready"}`. +- A readiness dependency failure returns bounded HTTP `503` evidence with `error=not_ready` and a retry-oriented next action. The response must not disclose database exception text, credentials, connection strings, SQL details, tenant identifiers, or HR values. +- Readiness deliberately does not probe read-only dedicated-writer dependencies such as Keyverse or Naruon. Their outages retain their separately governed fail-closed/degraded-mode semantics and must not be converted into an Orgmetra process-liveness failure. +- These routes are an executable operability contract, not a deployment or certification claim. Production orchestration still requires reviewed startup/liveness/readiness timing, network policy, resource limits, telemetry, SLO alerting, and recovery evidence on the released artifact. + ## Degraded modes ### Keyverse unavailable diff --git a/docs/doctoring/people-api-operability-references.md b/docs/doctoring/people-api-operability-references.md new file mode 100644 index 000000000..983c8d12f --- /dev/null +++ b/docs/doctoring/people-api-operability-references.md @@ -0,0 +1,15 @@ +# People API operability references + +## Scope + +These references support the active People API liveness/readiness design. They are design evidence only; they do not claim Kubernetes conformance, certification, production deployment, or release acceptance. + +## APA 7 references + +Kubernetes Authors. (2026). *Liveness, readiness, and startup probes*. Kubernetes. https://kubernetes.io/docs/concepts/workloads/pods/probes/ + +Kubernetes Authors. (2026). *Configure liveness, readiness and startup probes*. Kubernetes. https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ + +## Design use + +The Kubernetes documentation distinguishes liveness, which can trigger container restart, from readiness, which removes an unavailable Pod from service traffic. It also cautions that incorrectly coupling liveness to transient dependencies can create cascading failures and describes the pattern where readiness additionally verifies required backend services. Orgmetra applies that distinction by keeping `/health` dependency-free and binding `/ready` only to the People API's owned PostgreSQL availability check. diff --git a/docs/traceability/people-api-operability.md b/docs/traceability/people-api-operability.md new file mode 100644 index 000000000..50d03226f --- /dev/null +++ b/docs/traceability/people-api-operability.md @@ -0,0 +1,26 @@ +# People API operability traceability + +## Status + +This document describes the active PR that introduces executable People API probe semantics. Protected `develop@9e3e4847510e1e612b48474ba42b177b8ed824df` does not yet contain these routes, and this document does not claim production deployment or release readiness. + +## Requirement-to-evidence map + +| Requirement | Active implementation | Executable evidence | Failure rule | +|---|---|---|---| +| Process liveness must not depend on PostgreSQL availability | `PeopleOperabilityAsgiApp` `GET /health` | `test_health_is_live_without_touching_owned_dependencies` | PostgreSQL failure cannot make liveness fail or trigger a dependency call. | +| Traffic readiness must reflect the service-owned PostgreSQL dependency | `PeopleOperabilityAsgiApp` `GET /ready` + `PostgresReadinessProbe` | `test_ready_checks_owned_dependency_and_reports_success`, `test_postgres_probe_requires_callable_factory_and_a_result_row`, `test_postgres_probe_accepts_mapping_row_factory` | Readiness is 200 only after the reviewed read-only probe produces a row regardless of DB-API row factory. | +| Synchronous readiness work must not monopolize the ASGI event loop | `PeopleOperabilityAsgiApp` offloads `ReadinessProbe.check_ready` with `asyncio.to_thread` | `test_ready_keeps_event_loop_live_during_sync_probe` | The connection factory, cursor, transaction, and `SELECT 1` execute together on the worker thread while the ASGI loop remains schedulable. | +| Backend failure details must not cross the HTTP boundary | bounded `503 not_ready` response | `test_ready_normalizes_dependency_failure_without_leaking_details` | Any owned-dependency exception becomes a stable 503 with a next operator action and no backend message. | +| Probe routes must not become a hidden HR-data or identity integration path | no tenant context, credentials, HR table SQL, or foreign-service calls | source contract plus route tests | `/health` and `/ready` expose status only; Keyverse and other dedicated-writer services remain outside this boundary. | +| Unsupported transport shapes must stay bounded | exact GET-only `/health` and `/ready` surface | `test_unknown_route_and_wrong_method_are_bounded_transport_errors`, `test_non_http_scope_is_rejected_as_a_programming_error` | Unknown route is 404, wrong method is 405 with `Allow: GET`, non-HTTP ASGI is rejected. | + +## Operational interpretation + +Kubernetes distinguishes liveness from readiness: liveness is used to decide when a container should be restarted, while readiness determines whether it should receive traffic. The official Kubernetes probe guidance specifically warns that an incorrect liveness dependency can cause cascading restarts and notes that a strict backend dependency can be checked by readiness while liveness continues to reflect the application itself. Orgmetra therefore keeps PostgreSQL out of `/health` and checks it only in `/ready`. + +The PostgreSQL readiness query deliberately proves infrastructure availability only. It does not prove tenant authorization, HR-data correctness, migration compatibility, downstream integrations, or release acceptance. The constant `SELECT 1` contract requires a result row but does not prescribe whether a DB-API driver represents that row as a tuple, mapping, or another row-factory-owned shape. Because the DB-API probe is synchronous, the ASGI adapter offloads the complete probe call rather than moving a live connection or cursor between threads; this preserves connection-factory/context-manager ownership while preventing a slow readiness dependency from monopolizing unrelated event-loop work. Those concerns remain separate evidence gates. + +## Ownership boundary + +This slice writes only Orgmetra. PostgreSQL is an Orgmetra-owned runtime dependency. Keyverse, Naruon, contextual-orchestrator, and all other dedicated-writer CWL repositories are not queried or mutated by the probe and are not claimed healthy by its result. diff --git a/manifest.json b/manifest.json index 97f2bab14..bc2811923 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":"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":"c8839e24a8df803259c506c93b5ca34bd1c7db52b2ee7d7fcd31b94b95f47fc9","bytes":12692,"lines":79},{"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}]} \ No newline at end of file diff --git a/services/people-api/README.md b/services/people-api/README.md index 548a83446..6f8795643 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. +`PeopleOperabilityAsgiApp` is a separate PII-free probe surface for deployment composition. `GET /health` reports process liveness and never calls PostgreSQL, while `GET /ready` invokes only the injected Orgmetra-owned readiness dependency and returns `503 not_ready` with a bounded operator action when that dependency fails. `PostgresReadinessProbe` implements the production owned-database check as a read-only transaction containing only `SELECT 1`; it does not set tenant context, read HR tables, accept credentials, or reach into Keyverse/Naruon/other dedicated-writer services. Deployment routing and network policy should expose these endpoints only where orchestration requires them. Metrics, Kubernetes manifests, startup-probe timing, and release certification remain separate release work. + 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 b043bed33..48910035b 100644 --- a/services/people-api/src/orgmetra_people_api/__init__.py +++ b/services/people-api/src/orgmetra_people_api/__init__.py @@ -1,4 +1,4 @@ -"""Request-edge, governed read, confirmed-hire, and People mutation contracts.""" +"""Request-edge, governed read, confirmed-hire, mutation, and operability contracts.""" from orgmetra_people_api.auth import ( AuthenticatedPrincipal, @@ -32,6 +32,11 @@ create_employment_record, create_position_record, ) +from orgmetra_people_api.operability import ( + PeopleOperabilityAsgiApp, + PostgresReadinessProbe, + ReadinessProbe, +) from orgmetra_people_api.people import ( AuthorizedWorkerPeopleView, PeopleReadPort, @@ -59,6 +64,7 @@ "PeopleMutationIntegrityError", "PeopleMutationNotFound", "PeopleMutationPort", + "PeopleOperabilityAsgiApp", "PeopleReadPort", "PeopleRecordIntegrityError", "PeopleRecordNotFound", @@ -67,6 +73,8 @@ "PostgresHireAcceptancePort", "PostgresPeopleMutationPort", "PostgresPeopleReadPort", + "PostgresReadinessProbe", + "ReadinessProbe", "AssignmentMutationCommand", "AssignmentMutationResult", "EmploymentMutationCommand", diff --git a/services/people-api/src/orgmetra_people_api/operability.py b/services/people-api/src/orgmetra_people_api/operability.py new file mode 100644 index 000000000..327558969 --- /dev/null +++ b/services/people-api/src/orgmetra_people_api/operability.py @@ -0,0 +1,145 @@ +"""PII-free liveness and owned-dependency readiness surfaces for People API. + +Liveness proves only that the People API process can serve HTTP. Readiness is +stricter: it calls an injected probe for dependencies owned by this service and +returns unavailable until those dependencies can serve work. Identity providers +and other dedicated-writer CWL services are intentionally outside this probe so +the People API never reaches into their private implementation boundaries. +""" + +from __future__ import annotations + +import asyncio +from contextlib import AbstractContextManager +from dataclasses import dataclass +import json +from typing import Any, Awaitable, Callable, Mapping, Protocol, runtime_checkable + +AsgiReceive = Callable[[], Awaitable[dict[str, object]]] +AsgiSend = Callable[[dict[str, object]], Awaitable[None]] +PostgresConnectionFactory = Callable[[], AbstractContextManager[Any]] + +_READ_ONLY_SQL = "SET TRANSACTION READ ONLY" +_READINESS_SQL = "SELECT 1" + + +@runtime_checkable +class ReadinessProbe(Protocol): + """Check only dependencies whose availability is owned by the People API.""" + + def check_ready(self) -> None: + """Return normally when owned dependencies are ready; otherwise raise.""" + + +@dataclass(frozen=True, slots=True) +class PostgresReadinessProbe: + """Verify the Orgmetra PostgreSQL dependency without reading HR business data. + + Deployment code supplies the same kind of DB-API connection factory used by + the People persistence adapters. The probe enters a read-only transaction + and executes only ``SELECT 1``; it does not set tenant context or touch any + application table because readiness is infrastructure evidence, not an HR + data query. + """ + + connection_factory: PostgresConnectionFactory + + def __post_init__(self) -> None: + """Reject an unusable connection factory before serving readiness traffic.""" + if not callable(self.connection_factory): + raise TypeError("connection_factory must be callable") + + def check_ready(self) -> None: + """Raise when PostgreSQL cannot complete the reviewed read-only probe.""" + with self.connection_factory() as connection: + with connection.cursor() as cursor: + cursor.execute(_READ_ONLY_SQL) + cursor.execute(_READINESS_SQL) + row = cursor.fetchone() + if row is None: + raise RuntimeError("owned PostgreSQL readiness query returned no result") + + +@dataclass(frozen=True, slots=True) +class PeopleOperabilityAsgiApp: + """Expose dependency-light `/health` and `/ready` endpoints for orchestration. + + ``/health`` never invokes dependencies and therefore remains suitable for a + liveness probe that should not create restart loops during a database outage. + ``/ready`` offloads the supplied synchronous owned-dependency probe from the + ASGI event loop and returns HTTP 503 on any dependency failure. Neither route + accepts credentials, reads HR data, returns dependency details, or claims + that foreign CWL services are healthy. + """ + + readiness_probe: ReadinessProbe + + def __post_init__(self) -> None: + """Require a concrete readiness contract before the app can be served.""" + if not isinstance(self.readiness_probe, ReadinessProbe): + raise TypeError("readiness_probe must implement ReadinessProbe") + + async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send: AsgiSend) -> None: + """Serve one PII-free operability request with bounded failure responses.""" + del receive + if scope.get("type") != "http": + raise ValueError("PeopleOperabilityAsgiApp accepts only HTTP ASGI scopes") + + if scope.get("method") != "GET": + await _send_json( + send, + status=405, + payload={ + "error": "method_not_allowed", + "message": "Use GET for People API health and readiness probes.", + }, + extra_headers=((b"allow", b"GET"),), + ) + return + + path = scope.get("path") + if path == "/health": + await _send_json(send, status=200, payload={"status": "ok"}) + return + if path == "/ready": + try: + await asyncio.to_thread(self.readiness_probe.check_ready) + except Exception: # noqa: BLE001 - readiness must normalize dependency details. + await _send_json( + send, + status=503, + payload={ + "error": "not_ready", + "message": "Retry after an Orgmetra operator restores the owned People API dependency.", + }, + ) + return + await _send_json(send, status=200, payload={"status": "ready"}) + return + + await _send_json( + send, + status=404, + payload={ + "error": "route_not_found", + "message": "Use /health for liveness or /ready for owned-dependency readiness.", + }, + ) + + +async def _send_json( + send: AsgiSend, + *, + status: int, + payload: Mapping[str, object], + extra_headers: tuple[tuple[bytes, bytes], ...] = (), +) -> None: + """Emit deterministic non-cacheable JSON without HR or dependency details.""" + body = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8") + headers = ( + (b"content-type", b"application/json"), + (b"cache-control", b"no-store"), + *extra_headers, + ) + await send({"type": "http.response.start", "status": status, "headers": list(headers)}) + await send({"type": "http.response.body", "body": body, "more_body": False}) diff --git a/services/people-api/tests/test_operability_concurrency.py b/services/people-api/tests/test_operability_concurrency.py new file mode 100644 index 000000000..ab32eec45 --- /dev/null +++ b/services/people-api/tests/test_operability_concurrency.py @@ -0,0 +1,65 @@ +"""Concurrency regressions for the People API operability surface.""" + +from __future__ import annotations + +import asyncio +import threading +import unittest + +from orgmetra_people_api.operability import PeopleOperabilityAsgiApp + + +class BlockingReadinessProbe: + """Model synchronous DB-API work that needs the ASGI event loop to stay live.""" + + def __init__(self) -> None: + """Create one release signal and observable probe outcome.""" + self.calls = 0 + self.release_signal = threading.Event() + self.released_during_check = False + + def release(self) -> None: + """Release the simulated dependency wait from the event-loop callback.""" + self.release_signal.set() + + def check_ready(self) -> None: + """Wait briefly for loop progress and fail if the loop is monopolized.""" + self.calls += 1 + self.released_during_check = self.release_signal.wait(timeout=0.5) + if not self.released_during_check: + raise RuntimeError("ASGI event loop could not progress during readiness work") + + +class PeopleOperabilityConcurrencyTests(unittest.IsolatedAsyncioTestCase): + """Keep synchronous owned-dependency work off the ASGI event loop.""" + + async def test_ready_keeps_event_loop_live_during_sync_probe(self) -> None: + """A blocking readiness check must execute away from the ASGI event loop.""" + probe = BlockingReadinessProbe() + app = PeopleOperabilityAsgiApp(probe) + loop = asyncio.get_running_loop() + messages: list[dict[str, object]] = [] + + async def receive() -> dict[str, object]: + """Return an empty request frame; readiness never consumes a body.""" + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + """Capture the readiness response.""" + messages.append(message) + + # The callback can release the simulated DB wait only when the ASGI loop + # remains schedulable while synchronous readiness work is in progress. + loop.call_soon(probe.release) + await app({"type": "http", "method": "GET", "path": "/ready"}, receive, send) + + self.assertTrue( + probe.released_during_check, + "readiness must keep the ASGI event loop live during synchronous dependency work", + ) + self.assertEqual(probe.calls, 1) + self.assertEqual(messages[0]["status"], 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/people-api/tests/test_operability_http.py b/services/people-api/tests/test_operability_http.py new file mode 100644 index 000000000..c3e607446 --- /dev/null +++ b/services/people-api/tests/test_operability_http.py @@ -0,0 +1,211 @@ +"""Executable liveness and owned-dependency readiness contracts for People API.""" + +from __future__ import annotations + +import json +from pathlib import Path +import unittest + +from orgmetra_people_api.operability import PeopleOperabilityAsgiApp, PostgresReadinessProbe + + +class FakeReadinessProbe: + """Record readiness checks and optionally model an unavailable owned dependency.""" + + def __init__(self, *, error: Exception | None = None) -> None: + """Configure the probe with an optional failure raised during readiness.""" + self.error = error + self.calls = 0 + + def check_ready(self) -> None: + """Record one readiness check and raise the configured failure when present.""" + self.calls += 1 + if self.error is not None: + raise self.error + + +class FakeCursor: + """Capture SQL issued by the concrete PostgreSQL readiness probe.""" + + def __init__(self, *, row: object = (1,)) -> None: + """Configure the row returned by the readiness query.""" + self.row = row + self.executed: list[str] = [] + + def __enter__(self) -> FakeCursor: + """Enter the fake DB-API cursor context.""" + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + """Exit the fake cursor context without suppressing failures.""" + del exc_type, exc, traceback + + def execute(self, statement: str) -> None: + """Record one SQL statement exactly as the adapter issued it.""" + self.executed.append(statement) + + def fetchone(self) -> object: + """Return the configured readiness result row.""" + return self.row + + +class FakeConnection: + """Expose one fake cursor through the DB-API connection context shape.""" + + def __init__(self, cursor: FakeCursor) -> None: + """Bind the cursor returned by this connection.""" + self._cursor = cursor + + def __enter__(self) -> FakeConnection: + """Enter the fake connection context.""" + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + """Exit the fake connection context without suppressing failures.""" + del exc_type, exc, traceback + + def cursor(self) -> FakeCursor: + """Return the configured cursor.""" + return self._cursor + + +class PeopleOperabilityHttpTests(unittest.IsolatedAsyncioTestCase): + """Prove probes disclose no HR data and distinguish process from dependency health.""" + + async def _request( + self, + app: PeopleOperabilityAsgiApp, + *, + method: object = "GET", + path: object = "/health", + ) -> tuple[int, dict[bytes, bytes], dict[str, object]]: + """Execute one dependency-light ASGI request against the operability app.""" + scope = {"type": "http", "method": method, "path": path} + messages: list[dict[str, object]] = [] + + async def receive() -> dict[str, object]: + """Return one empty request frame; probes never consume request bodies.""" + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message: dict[str, object]) -> None: + """Capture an ASGI response message for assertions.""" + messages.append(message) + + await app(scope, receive, send) + start, body = messages + return ( + int(start["status"]), + dict(start["headers"]), + json.loads(bytes(body["body"])), + ) + + def test_constructor_requires_a_readiness_probe_contract(self) -> None: + """Reject incomplete dependency injection before a probe endpoint can serve.""" + with self.assertRaisesRegex(TypeError, "readiness_probe"): + PeopleOperabilityAsgiApp(readiness_probe=object()) + + def test_postgres_probe_requires_callable_factory_and_a_result_row(self) -> None: + """Use a read-only owned-database check and fail closed when SELECT 1 yields no row.""" + with self.assertRaisesRegex(TypeError, "connection_factory"): + PostgresReadinessProbe(connection_factory=object()) + + cursor = FakeCursor() + probe = PostgresReadinessProbe(connection_factory=lambda: FakeConnection(cursor)) + probe.check_ready() + self.assertEqual(cursor.executed, ["SET TRANSACTION READ ONLY", "SELECT 1"]) + + missing_cursor = FakeCursor(row=None) + missing_probe = PostgresReadinessProbe(connection_factory=lambda: FakeConnection(missing_cursor)) + with self.assertRaisesRegex(RuntimeError, "readiness query"): + missing_probe.check_ready() + + def test_postgres_probe_accepts_mapping_row_factory(self) -> None: + """Treat an equivalent mapping row as healthy instead of coupling readiness to tuple rows.""" + cursor = FakeCursor(row={"readiness_value": 1}) + probe = PostgresReadinessProbe(connection_factory=lambda: FakeConnection(cursor)) + + probe.check_ready() + + self.assertEqual(cursor.executed, ["SET TRANSACTION READ ONLY", "SELECT 1"]) + + def test_canonical_operability_doc_describes_the_probe_contract(self) -> None: + """Keep the canonical operator runbook aligned with the executable probe surface.""" + repository_root = Path(__file__).resolve().parents[3] + operability_text = (repository_root / "docs" / "OPERABILITY.md").read_text(encoding="utf-8") + + self.assertIn("### People API liveness and readiness", operability_text) + self.assertIn("`GET /health`", operability_text) + self.assertIn("`GET /ready`", operability_text) + self.assertIn("must not call PostgreSQL", operability_text) + self.assertIn("`SELECT 1`", operability_text) + self.assertIn("503", operability_text) + + def test_traceability_matches_driver_independent_readiness_row_contract(self) -> None: + """Do not let traceability reintroduce the tuple-only readiness contract that production removed.""" + repository_root = Path(__file__).resolve().parents[3] + traceability_text = ( + repository_root / "docs" / "traceability" / "people-api-operability.md" + ).read_text(encoding="utf-8") + + self.assertNotIn("returns exactly `(1,)`", traceability_text) + self.assertIn("produces a row regardless of DB-API row factory", traceability_text) + + async def test_health_is_live_without_touching_owned_dependencies(self) -> None: + """Keep liveness independent from PostgreSQL so orchestration avoids restart loops.""" + probe = FakeReadinessProbe(error=RuntimeError("database is down")) + status, headers, payload = await self._request(PeopleOperabilityAsgiApp(probe)) + + self.assertEqual((status, payload), (200, {"status": "ok"})) + self.assertEqual(headers[b"content-type"], b"application/json") + self.assertEqual(headers[b"cache-control"], b"no-store") + self.assertEqual(probe.calls, 0) + + async def test_ready_checks_owned_dependency_and_reports_success(self) -> None: + """Return ready only after the injected owned-dependency probe succeeds.""" + probe = FakeReadinessProbe() + status, _, payload = await self._request(PeopleOperabilityAsgiApp(probe), path="/ready") + + self.assertEqual((status, payload), (200, {"status": "ready"})) + self.assertEqual(probe.calls, 1) + + async def test_ready_normalizes_dependency_failure_without_leaking_details(self) -> None: + """Return a bounded 503 and useful next action without exposing DB secrets.""" + probe = FakeReadinessProbe(error=RuntimeError("postgres password=do-not-leak")) + status, _, payload = await self._request(PeopleOperabilityAsgiApp(probe), path="/ready") + + self.assertEqual(status, 503) + self.assertEqual(payload["error"], "not_ready") + self.assertIn("Retry", payload["message"]) + self.assertNotIn("password", json.dumps(payload)) + self.assertEqual(probe.calls, 1) + + async def test_unknown_route_and_wrong_method_are_bounded_transport_errors(self) -> None: + """Expose only the two reviewed probe routes and GET method.""" + app = PeopleOperabilityAsgiApp(FakeReadinessProbe()) + + status, _, payload = await self._request(app, path="/metrics") + self.assertEqual((status, payload["error"]), (404, "route_not_found")) + status, _, payload = await self._request(app, path=42) + self.assertEqual((status, payload["error"]), (404, "route_not_found")) + status, headers, payload = await self._request(app, method="POST", path="/health") + self.assertEqual((status, payload["error"]), (405, "method_not_allowed")) + self.assertEqual(headers[b"allow"], b"GET") + + async def test_non_http_scope_is_rejected_as_a_programming_error(self) -> None: + """Keep the operability adapter limited to HTTP ASGI scopes.""" + app = PeopleOperabilityAsgiApp(FakeReadinessProbe()) + + async def receive() -> dict[str, object]: + """Return one synthetic lifespan frame.""" + return {"type": "lifespan.startup"} + + async def send(message: dict[str, object]) -> None: + """Discard response messages; no response should be emitted.""" + del message + + with self.assertRaisesRegex(ValueError, "HTTP ASGI scopes"): + await app({"type": "lifespan"}, receive, send) + + +if __name__ == "__main__": + unittest.main()