pgcommon 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. {pgcommon-0.3.0 → pgcommon-0.4.0}/.claude/CLAUDE.md +3 -3
  2. {pgcommon-0.3.0 → pgcommon-0.4.0}/.claude/api-reference.md +3 -1
  3. {pgcommon-0.3.0 → pgcommon-0.4.0}/.claude/architecture.md +3 -2
  4. {pgcommon-0.3.0 → pgcommon-0.4.0}/.claude/operations.md +4 -4
  5. {pgcommon-0.3.0 → pgcommon-0.4.0}/ARCHITECTURE.md +16 -5
  6. {pgcommon-0.3.0 → pgcommon-0.4.0}/CHANGELOG.md +57 -0
  7. {pgcommon-0.3.0 → pgcommon-0.4.0}/PKG-INFO +97 -16
  8. {pgcommon-0.3.0 → pgcommon-0.4.0}/README.md +96 -15
  9. {pgcommon-0.3.0 → pgcommon-0.4.0}/VERSIONING.md +9 -9
  10. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/lld/platform-lld-pgcommon-py.md +40 -22
  11. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/observability/README.md +5 -0
  12. pgcommon-0.4.0/docs/problem-details.md +221 -0
  13. {pgcommon-0.3.0 → pgcommon-0.4.0}/pyproject.toml +1 -1
  14. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/sqlstate.py +20 -0
  15. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/config.py +19 -0
  16. pgcommon-0.4.0/src/pgcommon/integrations/_problem.py +149 -0
  17. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/integrations/fastapi.py +153 -16
  18. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/metrics.py +19 -4
  19. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/pool.py +9 -6
  20. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/tx.py +3 -2
  21. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_config.py +13 -0
  22. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_core_invariants.py +1 -1
  23. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_fastapi_invariants.py +4 -3
  24. pgcommon-0.4.0/tests/unit/test_fastapi_problem_details.py +423 -0
  25. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_integrations_fastapi.py +6 -3
  26. pgcommon-0.4.0/tests/unit/test_logged_error_text.py +237 -0
  27. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_metrics.py +78 -0
  28. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_pool.py +2 -2
  29. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_tx.py +4 -1
  30. {pgcommon-0.3.0 → pgcommon-0.4.0}/uv.lock +1 -1
  31. {pgcommon-0.3.0 → pgcommon-0.4.0}/.claude/database-schema.md +0 -0
  32. {pgcommon-0.3.0 → pgcommon-0.4.0}/.claude/request-flows.md +0 -0
  33. {pgcommon-0.3.0 → pgcommon-0.4.0}/.claude/settings.json +0 -0
  34. {pgcommon-0.3.0 → pgcommon-0.4.0}/.dockerignore +0 -0
  35. {pgcommon-0.3.0 → pgcommon-0.4.0}/.env-example +0 -0
  36. {pgcommon-0.3.0 → pgcommon-0.4.0}/.githooks/pre-commit +0 -0
  37. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/CODEOWNERS +0 -0
  38. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  39. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  40. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/SECURITY.md +0 -0
  41. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/dependabot.yml +0 -0
  42. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/pull_request_template.md +0 -0
  43. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/api-compat.sh +0 -0
  44. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/arch-lint.sh +0 -0
  45. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/changelog-section.sh +0 -0
  46. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/coverage-gate.sh +0 -0
  47. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/create-github-release.sh +0 -0
  48. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/detect-changes.sh +0 -0
  49. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/detect-changes_test.sh +0 -0
  50. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/docs-mermaid-sync.sh +0 -0
  51. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/export-image-provenance.sh +0 -0
  52. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/extract-release-notes.sh +0 -0
  53. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/image-fingerprint.sh +0 -0
  54. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/log-image-digest.sh +0 -0
  55. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/pr-summary.js +0 -0
  56. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/prepare-release-dist.sh +0 -0
  57. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/release-checksums.sh +0 -0
  58. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/release-image-tags.sh +0 -0
  59. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/release_guard.py +0 -0
  60. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/set-build-cache-targets.sh +0 -0
  61. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/set-source-date-epoch.sh +0 -0
  62. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/smoke-tests.sh +0 -0
  63. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/verify-changelog-entry.sh +0 -0
  64. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/scripts/verify-release-tag.sh +0 -0
  65. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/workflows/changelog-check.yml +0 -0
  66. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/workflows/ci.yml +0 -0
  67. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/workflows/docs.yml +0 -0
  68. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/workflows/release.yml +0 -0
  69. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/workflows/validate-quality.yml +0 -0
  70. {pgcommon-0.3.0 → pgcommon-0.4.0}/.github/workflows/validate-test.yml +0 -0
  71. {pgcommon-0.3.0 → pgcommon-0.4.0}/.gitignore +0 -0
  72. {pgcommon-0.3.0 → pgcommon-0.4.0}/CONTRIBUTING.md +0 -0
  73. {pgcommon-0.3.0 → pgcommon-0.4.0}/Dockerfile +0 -0
  74. {pgcommon-0.3.0 → pgcommon-0.4.0}/LICENSE +0 -0
  75. {pgcommon-0.3.0 → pgcommon-0.4.0}/Makefile +0 -0
  76. {pgcommon-0.3.0 → pgcommon-0.4.0}/SECURITY.md +0 -0
  77. {pgcommon-0.3.0 → pgcommon-0.4.0}/build-constraints.txt +0 -0
  78. {pgcommon-0.3.0 → pgcommon-0.4.0}/docker-compose.yml +0 -0
  79. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/MigrationGuide.md +0 -0
  80. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/adr/ADR-001-contextvars-guc-propagation.md +0 -0
  81. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/adr/ADR-002-cancellation-and-rollback-safety.md +0 -0
  82. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/adr/ADR-003-guc-injection-timing.md +0 -0
  83. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/adr/ADR-004-migration-runner-design.md +0 -0
  84. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/README.md +0 -0
  85. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/consuming-service-wiring.mmd +0 -0
  86. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/data-model.mmd +0 -0
  87. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/deployment.mmd +0 -0
  88. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/documentation-assets.mmd +0 -0
  89. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/error-classification.mmd +0 -0
  90. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/failure-domains.mmd +0 -0
  91. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/layer-model.mmd +0 -0
  92. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/migration-runner.mmd +0 -0
  93. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/no-identity-flow.mmd +0 -0
  94. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/observability-stack.mmd +0 -0
  95. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/package-dependencies.mmd +0 -0
  96. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/pool-create-hook-composition.mmd +0 -0
  97. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/pool-lifecycle.mmd +0 -0
  98. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/query-flow.mmd +0 -0
  99. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/retry-flow.mmd +0 -0
  100. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/rls-guc-flow.mmd +0 -0
  101. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/runinsavepoint-lifecycle.mmd +0 -0
  102. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/runintx-lifecycle.mmd +0 -0
  103. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/withconn-pgbouncer-mode.mmd +0 -0
  104. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/architecture/mermaid/withconn-standard-mode.mmd +0 -0
  105. {pgcommon-0.3.0 → pgcommon-0.4.0}/docs/observability/metrics-registry.md +0 -0
  106. {pgcommon-0.3.0 → pgcommon-0.4.0}/migrations/0001_create_example_table.down.sql +0 -0
  107. {pgcommon-0.3.0 → pgcommon-0.4.0}/migrations/0001_create_example_table.up.sql +0 -0
  108. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/__init__.py +0 -0
  109. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/__init__.py +0 -0
  110. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/dsn.py +0 -0
  111. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/duration.py +0 -0
  112. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/goquote.py +0 -0
  113. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/query_event.py +0 -0
  114. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/sql_verb.py +0 -0
  115. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/statement_scope.py +0 -0
  116. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/strconv.py +0 -0
  117. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/txshape.py +0 -0
  118. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/_internal/wrap_tx.py +0 -0
  119. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/errors.py +0 -0
  120. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/guc.py +0 -0
  121. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/integrations/__init__.py +0 -0
  122. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/logger.py +0 -0
  123. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/metrics_registry.py +0 -0
  124. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/migrate/__init__.py +0 -0
  125. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/migrate/__main__.py +0 -0
  126. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/migrate/cli.py +0 -0
  127. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/migrate/errors.py +0 -0
  128. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/migrate/runner.py +0 -0
  129. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/py.typed +0 -0
  130. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/retry.py +0 -0
  131. {pgcommon-0.3.0 → pgcommon-0.4.0}/src/pgcommon/tracer.py +0 -0
  132. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/__init__.py +0 -0
  133. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/conftest.py +0 -0
  134. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/e2e/__init__.py +0 -0
  135. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/e2e/conftest.py +0 -0
  136. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/e2e/test_core_invariants_e2e.py +0 -0
  137. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/e2e/test_pgbouncer_callback_escape_e2e.py +0 -0
  138. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/e2e/test_rls_pgbouncer_e2e.py +0 -0
  139. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/fixtures/__init__.py +0 -0
  140. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/fixtures/fake_asyncpg.py +0 -0
  141. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/fixtures/fake_migrate_conn.py +0 -0
  142. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/fixtures/fakes.py +0 -0
  143. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/fixtures/go_pgmetrics_vectors.go.txt +0 -0
  144. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/fixtures/go_pgmetrics_vectors.json +0 -0
  145. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/__init__.py +0 -0
  146. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/conftest.py +0 -0
  147. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_concurrency_stress.py +0 -0
  148. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_connection_loss.py +0 -0
  149. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_core_invariants_integration.py +0 -0
  150. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_error_helpers_real_violations.py +0 -0
  151. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_failover.py +0 -0
  152. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_migrate_invariants_integration.py +0 -0
  153. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_migrate_runner_integration.py +0 -0
  154. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_platform_metrics.py +0 -0
  155. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_pool_hardening.py +0 -0
  156. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_pool_lifecycle.py +0 -0
  157. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_pool_regressions.py +0 -0
  158. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_retry_real_serialization_failure.py +0 -0
  159. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_rls_guc_injection.py +0 -0
  160. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_tx_and_savepoint.py +0 -0
  161. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/integration/test_tx_escape.py +0 -0
  162. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/__init__.py +0 -0
  163. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_config_parity.py +0 -0
  164. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_core_redaction_and_config.py +0 -0
  165. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_errors.py +0 -0
  166. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_guc.py +0 -0
  167. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_internal.py +0 -0
  168. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_internal_parity.py +0 -0
  169. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_logger.py +0 -0
  170. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_metrics_identity.py +0 -0
  171. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_metrics_invariants.py +0 -0
  172. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_metrics_inventory.py +0 -0
  173. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_metrics_lifecycle.py +0 -0
  174. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_metrics_standard.py +0 -0
  175. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_migrate_cli.py +0 -0
  176. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_migrate_cli_invariants.py +0 -0
  177. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_migrate_invariants.py +0 -0
  178. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_migrate_runner.py +0 -0
  179. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_pool_hardening.py +0 -0
  180. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_pool_metrics.py +0 -0
  181. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_pool_parity.py +0 -0
  182. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_pool_resilience.py +0 -0
  183. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_public_api.py +0 -0
  184. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_retry.py +0 -0
  185. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_tracer.py +0 -0
  186. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_txshape.py +0 -0
  187. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_txshape_go_oracle.py +0 -0
  188. {pgcommon-0.3.0 → pgcommon-0.4.0}/tests/unit/test_wrap_tx.py +0 -0
@@ -4,9 +4,9 @@ This file provides guidance to Claude Code when working with **platform-pgcommon
4
4
 
5
5
  ## What This Repo Is
6
6
 
7
- `platform-pgcommon-py` is the **Python package `pgcommon`** (`pyproject.toml`: version `0.3.0`, `requires-python >=3.12`, CI on 3.12 / 3.13 / 3.14; runtime dependencies `asyncpg>=0.30,<0.32`, `prometheus-client>=0.20`, `opentelemetry-api>=1.24`; extras `fastapi`, `structlog`; managed with **uv**, `uv.lock` committed). It is published to PyPI as `pgcommon` (Apache-2.0; the repository is private) and is **never deployed**. Its one shipped artefact besides the package is the `pgcommon-migrate` CLI (`pgcommon.migrate.cli:main`), published as a container image `ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`. It tracks Go **v2.0.1**. Consumers: `platform-eventcommon` (outbox / inbox / DLQ; still pins `pgcommon>=0.2.0,<0.3.0`), the FastAPI services built on the platform, and `platform-interop-tests`' Python probe (CI `interop` job).
7
+ `platform-pgcommon-py` is the **Python package `pgcommon`** (`pyproject.toml`: version `0.4.0`, `requires-python >=3.12`, CI on 3.12 / 3.13 / 3.14; runtime dependencies `asyncpg>=0.30,<0.32`, `prometheus-client>=0.20`, `opentelemetry-api>=1.24`; extras `fastapi`, `structlog`; managed with **uv**, `uv.lock` committed). It is published to PyPI as `pgcommon` (Apache-2.0; the repository is private) and is **never deployed**. Its one shipped artefact besides the package is the `pgcommon-migrate` CLI (`pgcommon.migrate.cli:main`), published as a container image `ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`. It tracks Go **v2.0.1**. Consumers: `platform-eventcommon` (outbox / inbox / DLQ; `pgcommon>=0.2.0,<0.5.0`), the FastAPI services built on the platform, and `platform-interop-tests`' Python probe (CI `interop` job).
8
8
 
9
- **Status:** 0.3.0 was released on 2026-10-15 (`CHANGELOG.md` `[0.3.0]`; breaking against 0.2.0). New work goes under `[Unreleased]`. Release process: [`operations.md`](operations.md) § Release state.
9
+ **Status:** 0.4.0 was released on 2026-10-16 (`CHANGELOG.md` `[0.4.0]`; breaking for HTTP clients of the FastAPI integration's 401 / 503); before it 0.3.1 and 0.3.0 (2026-10-15, breaking against 0.2.0). New work goes under `[Unreleased]`. Release process: [`operations.md`](operations.md) § Release state.
10
10
 
11
11
  **Key responsibilities (the library is the sole implementation of):**
12
12
  - **Connection pool** — `Pool.create(config)` wraps `asyncpg.create_pool`. It validates the config and the DSN before connecting (fail fast, then one ping), resolves each setting **explicit `Config` field ▸ DSN `pool_*` ▸ default** (max 10; min 2 capped at max, 0 in PgBouncer mode; per-connection lifetime 1 h + up to lifetime ÷ 10 jitter; idle 30 m; health check 1 m; `acquire_timeout` 30 s), and exposes **no** raw `asyncpg.Pool` or connection outside a callback.
@@ -16,7 +16,7 @@ This file provides guidance to Claude Code when working with **platform-pgcommon
16
16
  - **Migration runner** — `pgcommon.migrate.Runner` + `pgcommon-migrate`: a from-scratch asyncio runner sharing golang-migrate's tracking table, dirty protocol and advisory-lock key with the Go runner. The library ships **no** migrations.
17
17
  - **Database-client telemetry** — the 10 Tier 1 `platform_db_*` metrics (**Proposed**; no legacy family), OTel `db.query` spans, the rate-limited slow-query log.
18
18
 
19
- **Does NOT own:** consumers' tables, RLS policies, roles or migrations; authentication (the service decides the `GUCSet`); the logger, OTel SDK or `CollectorRegistry` (passed in); HTTP status mapping; PgBouncer / RDS Proxy; outbox / inbox (`platform-eventcommon`); alert rules, dashboards, runbook (Go's apply unchanged); metric ratification.
19
+ **Does NOT own:** consumers' tables, RLS policies, roles or migrations; authentication (the service decides the `GUCSet`); the logger, OTel SDK or `CollectorRegistry` (passed in); HTTP status mapping (beyond the FastAPI integration's own 401 / 503 RFC 9457 problems — `docs/problem-details.md`); PgBouncer / RDS Proxy; outbox / inbox (`platform-eventcommon`); alert rules, dashboards, runbook (Go's apply unchanged); metric ratification.
20
20
 
21
21
  ## Common Commands
22
22
 
@@ -111,7 +111,9 @@ The public surface is the `__all__` of `pgcommon`, `pgcommon.config`, `pgcommon.
111
111
 
112
112
  ### `pgcommon.integrations.fastapi` (extra `fastapi`)
113
113
 
114
- `create_lifespan(config)` (`Pool.create` → `app.state.pool`; `drain_and_close()` at shutdown) · `get_pool(request)` · `GUCAuthMiddleware(app, identity_extractor=...)` (`IDENTITY_ERRORS` → `401 {"detail": "invalid identity"}`; else `with with_guc_set(g): await call_next(request)`) · `IdentityExtractor` protocol · `healthz(*, utilization_threshold=0.9)` (503 when unhealthy or over the threshold).
114
+ `create_lifespan(config)` (`Pool.create` → `app.state.pool`; `drain_and_close()` at shutdown) · `get_pool(request)` · `GUCAuthMiddleware(app, identity_extractor=..., *, problem_type_base=..., problem_factory=None, logger=None, service="")` (`IDENTITY_ERRORS` → 401 RFC 9457 problem `{base}/platform/unauthorized`, `errorCode` `UNAUTHORIZED`, generic `detail`, never a `tenantId`; else `with with_guc_set(g): await call_next(request)`) · `IdentityExtractor` protocol · `healthz(*, utilization_threshold=0.9, problem_type_base=..., problem_factory=None, logger=None, service="")` (503 problem `{base}/platform/service-unavailable`, `errorCode` `DATABASE_UNAVAILABLE` / `SERVICE_OVERLOADED`; 200 body unchanged) · `ProblemSpec` (frozen: `type`, `title`, `status`, `error_code`, `detail`, `instance`, `trace_id`, `tenant_id`) · `ProblemFactory = Callable[[ProblemSpec, Request], Response]` (replaces the built-in builder) · `DEFAULT_PROBLEM_TYPE_BASE` (`https://errors.company.com`) · `PROBLEM_MEDIA_TYPE`.
115
+
116
+ Problems (private builder `integrations/_problem.py`): `content-type: application/problem+json`; fields `type`, `title`, `status`, `detail`, `instance` (path only), `traceId`, `errorCode`, `timestamp` (RFC 3339 UTC, seconds), `tenantId` only with a trusted tenant context. `traceId`: valid `traceparent` ▸ sanitized `x-request-id` (≤ 128 chars of `[A-Za-z0-9._:-]`, else ignored) ▸ active OTel span ▸ `secrets.token_hex(16)`; body only, no response header. Logged once at WARN (`pgcommon: problem response`: `traceId`, `type`, `status`, `errorCode`, `service`, `tenantId`) through `logger`, else the lifespan pool's `Config.logger`; `service` = argument, else `metrics.current_set().identity.service`. `detail` is always a fixed sentence: never `str(exc)`, SQL or identity values. Recommended mapping of database errors for services: `docs/problem-details.md`.
115
117
 
116
118
  ## 4. Exceptions
117
119
 
@@ -32,7 +32,8 @@ platform-pgcommon-py/
32
32
  │ ├── metrics_registry.py # registry(), lookup(), RegistryEntry, RegistryStatus, label vocabularies, buckets
33
33
  │ ├── migrate/ # runner.py (Runner, Migration, DEFAULT_*, _advisory_lock_id, _go_url_path, _Session);
34
34
  │ │ # cli.py (pgcommon-migrate, EXIT_*); errors.py (DuplicateMigrationError + re-exports); __main__.py
35
- │ ├── integrations/fastapi.py # create_lifespan, get_pool, GUCAuthMiddleware, IdentityExtractor, healthz (extra `fastapi`)
35
+ │ ├── integrations/fastapi.py # create_lifespan, get_pool, GUCAuthMiddleware, IdentityExtractor, healthz, ProblemSpec / ProblemFactory (extra `fastapi`)
36
+ │ ├── integrations/_problem.py # private, framework-free RFC 9457 body / trace-id / log helpers for fastapi.py
36
37
  │ ├── py.typed
37
38
  │ └── _internal/ # PRIVATE — dependency sinks
38
39
  │ ├── dsn.py # is_url_dsn, normalize_dsn, dsn_settings, keyword_database, check_asyncpg_url,
@@ -119,5 +120,5 @@ Acquire with the `begin` stage (`begin_tx`: `BEGIN` with isolation / read-only /
119
120
  ## How Platform Libraries Use pgcommon
120
121
 
121
122
  - **`platform-eventcommon`** holds a `pgcommon.Pool`; outbox / inbox / DLQ work runs through `pgcommon.run_in_tx`; `outbox.enqueue` writes with the **caller's** `run_in_tx` `conn` (atomic with the business write); its consumer binds the tenant with `with_guc_set`; it applies its own schema (not `Runner`) and raises `MigrationDirtyError`. It pins `pgcommon<0.3.0` (LLD §16 A1).
122
- - **FastAPI services:** `init_with_identity(...)` → `config_from_env()` → `guc_provider = guc_set_from_context` → `Pool.create` (or `create_lifespan(config)`) → `GUCAuthMiddleware` with the service's `IdentityExtractor` → repositories call `pool.with_conn` / `run_in_tx`. Cross-tenant work uses a second pool on a `BYPASSRLS` role with **no** `guc_provider`.
123
+ - **FastAPI services:** `init_with_identity(...)` → `config_from_env()` → `guc_provider = guc_set_from_context` → `Pool.create` (or `create_lifespan(config)`) → `GUCAuthMiddleware` with the service's `IdentityExtractor` (its 401 and `healthz`'s 503s are RFC 9457 problems; pass the service's `problem_factory` / `problem_type_base` so they match its own) → repositories call `pool.with_conn` / `run_in_tx`, and the service maps database errors to problems as in `docs/problem-details.md` (never `str(exc)` in a response). Cross-tenant work uses a second pool on a `BYPASSRLS` role with **no** `guc_provider`.
123
124
  - **`platform-fastapicommon`:** no dependency in either direction; share one `CollectorRegistry` and identity.
@@ -52,10 +52,10 @@ Trunk-based on `main`; SemVer, pre-1.0 (a MINOR may break). Full procedure: `VER
52
52
  2. Locally: `make ci`, `make release-guard TAG=vX.Y.Z` (`release_guard.py`: pyproject version = tag, PEP 440; `verify-changelog-entry.sh`: the section exists), `make api-compat API_NEW_VERSION=vX.Y.Z` (griffe; breaking needs a major — or a minor while 0.y).
53
53
  3. `git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z` → **`release.yml`**: `verify` (`verify-release-tag.sh`: dispatch ref, tag at HEAD, reachable from `origin/main`; release guard; CHANGELOG; `release-image-tags.sh`) → the same gates as `ci.yml` at the tag (api-compat **blocking**, interop **gating**) → `dist` (reproducible wheel + sdist, `release_guard.py --dist`, `twine check`, `SHA256SUMS`) → `push` (image `X.Y.Z`, forward-only `X.Y` / `X`, no `latest`; Cosign) → `pypi-publish` (Trusted Publishing, environment `pypi`; last irreversible step) → `publish` (GitHub Release with notes from the CHANGELOG section).
54
54
 
55
- ### Release state (2026-10-15)
55
+ ### Release state (2026-10-16)
56
56
 
57
- - **0.3.0 is the current release** (2026-10-15); before it **0.2.0** (2026-07-27) and 0.1.0 (2026-07-26).
58
- - **`platform-eventcommon`:** supports 0.3.0 from `pgcommon>=0.2.0,<0.4.0` (it re-raises its own `TxEndedInCallbackError` when pgcommon wraps it, and its fake pool goes through the public `Pool.create`). The interop gate checks the pair at `platform-interop-tests` `9bd44ca`. LLD §16 A1.
57
+ - **0.4.0 is the current release** (2026-10-16); before it **0.3.1** and **0.3.0** (2026-10-15), 0.2.0 (2026-07-27) and 0.1.0 (2026-07-26).
58
+ - **`platform-eventcommon`:** admits 0.4.0 with `pgcommon>=0.2.0,<0.5.0` (eventcommon PR #16; until it merges the interop gate fails) (it re-raises its own `TxEndedInCallbackError` when pgcommon wraps it, and its fake pool goes through the public `Pool.create`). The interop gate checks the pair at `platform-interop-tests` `9bd44ca`. LLD §16 A1.
59
59
  - **GitHub settings:** grant `python-platfrom` write access (CODEOWNERS); `GO_PRIVATE_TOKEN` must stay readable across the six platform libraries for interop.
60
60
  - **Upstream reports:** Go's `dsnutil` has the same redaction gap fixed here in 829d80e (a `?password=` value only redacted decoded; escapes not derived from the decoded password); asyncpg's holder leak on a protocol error (LLD §16 A2).
61
61
 
@@ -94,7 +94,7 @@ Trunk-based on `main`; SemVer, pre-1.0 (a MINOR may break). Full procedure: `VER
94
94
 
95
95
  | Consumer | On | Notes |
96
96
  |---|---|---|
97
- | `platform-eventcommon` | `pgcommon>=0.2.0,<0.3.0` | Outbox / inbox / DLQ on `Pool` + `run_in_tx`; needs §4 blocker fixes for 0.3.0 |
97
+ | `platform-eventcommon` | `pgcommon>=0.2.0,<0.5.0` | Outbox / inbox / DLQ on `Pool` + `run_in_tx` |
98
98
  | `platform-interop-tests/python-probe` | this branch (CI) | `Pool`, `GUCSet`, `with_guc_set`, `with_conn` in both modes |
99
99
  | FastAPI services | — | Wiring in architecture.md |
100
100
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  This document describes the internal structure, dependency rules, and runtime data flows of `pgcommon` — and, where it matters, exactly how the `asyncio`/`contextvars` translation differs from the Go original's `context.Context`/goroutine model.
4
4
 
5
- `pgcommon` (repository `platform-pgcommon-py`) is the **Python sibling of the private Go library `platform-pgcommon`**, currently at **0.3.0** (released 2026-10-15 — see [Versions and release state](#versions-and-release-state)) and tracking Go **v2.0.1**. It is an `asyncio` library on top of **asyncpg** (`asyncpg>=0.30,<0.32`, with `prometheus-client` and `opentelemetry-api` as its only other runtime dependencies; Python ≥ 3.12), published on PyPI as `pgcommon` and imported by every Python platform service that reaches PostgreSQL. It owns the **database access boundary**: the connection pool (an `asyncpg.Pool` underneath, with pgxpool's lifetime, idle, health-check and min-connection semantics rebuilt on top of it), Row-Level Security identity injection (the `app.user_id` / `app.tenant_id` / `app.tenant_roles` GUCs), transactions with deadlock/serialization retry and savepoints, SQLSTATE classification, a golang-migrate-compatible migration runner, and the database-client observability every service emits (Tier 1 `platform_db_*` metrics under the Enterprise Platform Observability Standard, identical to Go's; OTel spans; a slow-query log). It is consumed as a package dependency, not deployed. Today its consumers are `platform-eventcommon` (outbox / inbox / DLQ; `pgcommon>=0.2.0,<0.4.0`), the Python probe of `platform-interop-tests`, and the FastAPI services built on the platform. The one thing it ships as an artefact is **the `pgcommon-migrate` CLI**, published as a container image for running a service's migrations as a deploy job.
5
+ `pgcommon` (repository `platform-pgcommon-py`) is the **Python sibling of the private Go library `platform-pgcommon`**, currently at **0.4.0** (released 2026-10-16 — see [Versions and release state](#versions-and-release-state)) and tracking Go **v2.0.1**. It is an `asyncio` library on top of **asyncpg** (`asyncpg>=0.30,<0.32`, with `prometheus-client` and `opentelemetry-api` as its only other runtime dependencies; Python ≥ 3.12), published on PyPI as `pgcommon` and imported by every Python platform service that reaches PostgreSQL. It owns the **database access boundary**: the connection pool (an `asyncpg.Pool` underneath, with pgxpool's lifetime, idle, health-check and min-connection semantics rebuilt on top of it), Row-Level Security identity injection (the `app.user_id` / `app.tenant_id` / `app.tenant_roles` GUCs), transactions with deadlock/serialization retry and savepoints, SQLSTATE classification, a golang-migrate-compatible migration runner, and the database-client observability every service emits (Tier 1 `platform_db_*` metrics under the Enterprise Platform Observability Standard, identical to Go's; OTel spans; a slow-query log). It is consumed as a package dependency, not deployed. Today its consumers are `platform-eventcommon` (outbox / inbox / DLQ; `pgcommon>=0.2.0,<0.4.0`), the Python probe of `platform-interop-tests`, and the FastAPI services built on the platform. The one thing it ships as an artefact is **the `pgcommon-migrate` CLI**, published as a container image for running a service's migrations as a deploy job.
6
6
 
7
7
  See [`docs/MigrationGuide.md`](docs/MigrationGuide.md) for the concept mapping between the two languages, [Divergences from Go](#divergences-from-go) for the places where asyncpg cannot match Go, the ADRs under [`docs/adr/`](docs/adr/) for the riskiest translation choices, and the LLD [`docs/lld/platform-lld-pgcommon-py.md`](docs/lld/platform-lld-pgcommon-py.md) for the invariant catalogue (`PGPY-*`).
8
8
 
@@ -187,7 +187,7 @@ graph LR
187
187
  | `pgcommon.metrics_registry` | `registry()`, `lookup()`, `operation_values()`, `RegistryEntry` and the approved label vocabularies — the checked-in Platform Observability Registry entry |
188
188
  | `pgcommon.logger` | `Logger` (protocol), `StdlibLogger`, `StructlogLogger` |
189
189
  | `pgcommon.migrate` | `Runner`, `Migration`, `DEFAULT_MIGRATIONS_TABLE`, `DEFAULT_LOCK_TIMEOUT`, `DEFAULT_CONNECT_TIMEOUT`, `MigrationDirtyError`, `VersionNotInSourceError`, `NoMigrationsFoundError`, `DuplicateMigrationError`, `ShortLimitError`, `MigrationStoppedError`, `InvalidMigrationsTableError` |
190
- | `pgcommon.integrations.fastapi` | `create_lifespan`, `get_pool`, `GUCAuthMiddleware`, `IdentityExtractor`, `healthz` (requires `pgcommon[fastapi]`) |
190
+ | `pgcommon.integrations.fastapi` | `create_lifespan`, `get_pool`, `GUCAuthMiddleware`, `IdentityExtractor`, `healthz`, `ProblemSpec`, `ProblemFactory`, `DEFAULT_PROBLEM_TYPE_BASE`, `PROBLEM_MEDIA_TYPE` (requires `pgcommon[fastapi]`); the RFC 9457 helpers live in the private `integrations/_problem.py` |
191
191
 
192
192
  The top-level `pgcommon` package re-exports `Config`, `ConfigWarning`, `config_from_env` and `migration_dsn_from_env` (not the `DEFAULT_*` constants), every public name of `errors` except `CONNECTION_LOST_NOTE` (`DuplicateMigrationError` lives in `pgcommon.migrate` only), and everything public in `guc`, `pool`, `tx`, `retry`, `tracer` and `logger`, plus `sql_operation`, `OTelQueryRecorder`, `RegistrationWarning`, `MetricsIdentity` (`metrics.Identity`) and `pgmetrics_init_with_identity` (`metrics.init_with_identity`).
193
193
 
@@ -850,6 +850,8 @@ flowchart TD
850
850
 
851
851
  `is_pg_error` is the transport split: did PostgreSQL answer (an `asyncpg.PostgresError`), or did the client or the network fail? asyncpg's own client-side errors that carry a class-level SQLSTATE (`ConnectionDoesNotExistError`, 08003) are not server errors, so `sql_state` and the class helpers ignore them, as Go's do for non-`*pgconn.PgError` errors. `is_connection_lost` is a Python addition: pgx reports most lost connections as non-`PgError` errors that Go services classify with `!IsPgError(err)`, but asyncpg's shapes vary (`ConnectionDoesNotExistError`, `InterfaceError`, `InternalClientError`), so pgcommon adds `CONNECTION_LOST_NOTE` to any error raised on a connection that turned out to be closed by the server or network, and the helper names them all. Class 57 is split on purpose: `57014 query_canceled` is the caller's own `statement_timeout` or cancellation (`is_query_canceled`), while the rest of class 57 (`is_operator_intervention`) means the server is going away, like class 58 (`is_system_error`). The same split drives `health()`'s liveness stamp.
852
852
 
853
+ **From classification to HTTP.** pgcommon never builds an HTTP response for a database error. The service maps the classification above to an RFC 9457 problem; [docs/problem-details.md](docs/problem-details.md) has the recommended table (409 `platform/conflict` for unique violations and exhausted serialization/deadlock retries, 422 / 400 for constraint violations, 503 `platform/service-unavailable` for the "database is the problem" branches, 500 `platform/internal-error` for the callback bugs and everything else). It also has the rule that matters most: an exception's text (SQL, a constraint's `DETAIL` row values) never goes into a response, only into server-side logs. The FastAPI integration emits only two problems of its own: the 401 and the health 503s ([Consumer conformance checklist](#consumer-conformance-checklist)).
854
+
853
855
  Library sentinels are checked with `isinstance` (the Python `errors.Is`): `IDENTITY_ERRORS` (an identity that should have been rejected at the request edge; the pool re-raises it as a new instance of the same class, `pgcommon: invalid GUC identity: …`, original as `__cause__`), `TxEndedInCallbackError` / `TxCommitRollbackError` (callback bugs; fn's own exception is `callback_error`), `AcquireTimeoutError` (a `TimeoutError`), and the runner's `MigrationDirtyError` / `VersionNotInSourceError`, which a service running `up` at startup should treat as "refuse to start". A connection interrupted by a timed-out `drain_and_close` keeps its error's type and gains `POOL_INTERRUPTED_NOTE`, so `is_pool_closed` matches it.
854
856
 
855
857
  ### Error-chain sanitization
@@ -961,7 +963,7 @@ sequenceDiagram
961
963
  Note over MW,DB: Canonical tests: tests/integration/test_rls_guc_injection.py · test_concurrency_stress.py<br/>test_tx_escape.py · tests/e2e/test_rls_pgbouncer_e2e.py · test_pgbouncer_callback_escape_e2e.py
962
964
  ```
963
965
 
964
- `Config.guc_provider = pgcommon.guc_set_from_context` is the single security-critical wiring line in a consuming service. `with_validated_guc_set` (or `GUCAuthMiddleware`, which validates and answers 401) is preferred over `with_guc_set` in middleware, so an invalid identity is rejected at the request edge with a clear error. A service that needs a cross-tenant connection (reconcilers, exporters) should build a **second pool** on a `BYPASSRLS` role with **no** `guc_provider`, so a cross-tenant query is never confused with an app-pool query that happened to carry an empty identity. Work started from a request (`BackgroundTasks`, `asyncio.create_task`) inherits the request's identity by design; a long-lived worker must be started from the lifespan or in a fresh `contextvars.Context()` (see [`pgcommon/integrations/fastapi.py`](src/pgcommon/integrations/fastapi.py) and [ADR-001](docs/adr/ADR-001-contextvars-guc-propagation.md)).
966
+ `Config.guc_provider = pgcommon.guc_set_from_context` is the single security-critical wiring line in a consuming service. `with_validated_guc_set` (or `GUCAuthMiddleware`, which validates and answers an RFC 9457 401 problem that carries neither the identity's values nor a `tenantId`) is preferred over `with_guc_set` in middleware, so an invalid identity is rejected at the request edge with a clear error. A service that needs a cross-tenant connection (reconcilers, exporters) should build a **second pool** on a `BYPASSRLS` role with **no** `guc_provider`, so a cross-tenant query is never confused with an app-pool query that happened to carry an empty identity. Work started from a request (`BackgroundTasks`, `asyncio.create_task`) inherits the request's identity by design; a long-lived worker must be started from the lifespan or in a fresh `contextvars.Context()` (see [`pgcommon/integrations/fastapi.py`](src/pgcommon/integrations/fastapi.py) and [ADR-001](docs/adr/ADR-001-contextvars-guc-propagation.md)).
965
967
 
966
968
  ### Why GUC-based tenancy?
967
969
 
@@ -1244,9 +1246,9 @@ Run migrations from a **direct** connection, never through PgBouncer: the adviso
1244
1246
 
1245
1247
  ### Versions and release state
1246
1248
 
1247
- The current release is **0.3.0** (2026-10-15, `CHANGELOG.md` `[0.3.0]`): Go v2.0.1 parity and production hardening — Tier 1 `platform_db_*` metrics only with a mandatory identity, a golang-migrate-compatible runner and Go's `cmd/migrate` CLI, `TxEndedInCallbackError` / `TxCommitRollbackError`, GUC injection moved out of asyncpg's `setup` hook, per-connection lifetime, idle expiry and top-up, `acquire_timeout` / `command_timeout` / TCP keepalive, release that never raises, and the prepared-transaction guard. It is breaking against 0.2.0 (pre-1.0, a minor may break; see [`VERSIONING.md`](VERSIONING.md)). Before it: **0.2.0** (2026-07-27, `Runner(table=)`, default table `pgcommon_migrations`) and **0.1.0** (2026-07-26, the initial port). The CHANGELOG names the Go version each release tracks.
1249
+ The current release is **0.4.0** (2026-10-16, `CHANGELOG.md` `[0.4.0]`): the FastAPI integration's 401 and health 503s are RFC 9457 Problem Details (breaking for HTTP clients that parse the old `{"detail": …}` body), a failed statement's span error text matches Go's without PostgreSQL's DETAIL / HINT lines, no logged `error` field carries row values, and the health-check failure log has the `pgcommon:` prefix. Before it: **0.3.1** (2026-10-15, the `Config` / `ResolvedConfig` repr masks the DSN password) and **0.3.0** (2026-10-15, `CHANGELOG.md` `[0.3.0]`): Go v2.0.1 parity and production hardening — Tier 1 `platform_db_*` metrics only with a mandatory identity, a golang-migrate-compatible runner and Go's `cmd/migrate` CLI, `TxEndedInCallbackError` / `TxCommitRollbackError`, GUC injection moved out of asyncpg's `setup` hook, per-connection lifetime, idle expiry and top-up, `acquire_timeout` / `command_timeout` / TCP keepalive, release that never raises, and the prepared-transaction guard. It is breaking against 0.2.0 (pre-1.0, a minor may break; see [`VERSIONING.md`](VERSIONING.md)). Before that: **0.2.0** (2026-07-27, `Runner(table=)`, default table `pgcommon_migrations`) and **0.1.0** (2026-07-26, the initial port). The CHANGELOG names the Go version each release tracks.
1248
1250
 
1249
- **Before 0.3.0 can be released** (LLD §16 A1, A5): `platform-eventcommon` must widen its `pgcommon<0.3.0` pin, update its fake-pool fixture to 0.3.0's `Pool` internals and handle a `TxEndedInCallbackError` that wraps its own error — until then the cross-language interop gate fails, and interop gates the release's `push`; and two GitHub settings are needed (the `python-platfrom` team needs **write** access for `CODEOWNERS` to apply; `GO_PRIVATE_TOKEN` must stay readable across the platform libraries for interop). Go's `dsnutil` has the redaction gap this port closed (`829d80e`); it is to be reported upstream (LLD §16 A4).
1251
+ **Consumers and open items** (LLD §16 A1, A5): `platform-eventcommon` admits 0.4.0 with `pgcommon>=0.2.0,<0.5.0` (eventcommon PR #16); the cross-language interop gate, which gates the release's `push`, checks the pair. Two GitHub settings are needed (the `python-platfrom` team needs **write** access for `CODEOWNERS` to apply; `GO_PRIVATE_TOKEN` must stay readable across the platform libraries for interop). Go's `dsnutil` has the redaction gap this port closed (`829d80e`); it is to be reported upstream (LLD §16 A4).
1250
1252
 
1251
1253
  ---
1252
1254
 
@@ -1328,6 +1330,7 @@ graph LR
1328
1330
  - [ ] `fn` passed to `run_in_tx_with_retry(_opts)` is safe to re-run and performs no external side effect.
1329
1331
  - [ ] No `COMMIT` / `ROLLBACK` / `conn.transaction()` used to end the transaction inside a PgBouncer-mode `with_conn` or a `run_in_tx` callback; use `run_in_savepoint` (or a nested `conn.transaction()`) for partial rollback.
1330
1332
  - [ ] Errors are classified with the `is_*` helpers, `isinstance` on the sentinels or `sql_state`, not by matching message strings; `AcquireTimeoutError` and `is_connection_lost` map to 503.
1333
+ - [ ] HTTP errors are RFC 9457 problems mapped as in [docs/problem-details.md](docs/problem-details.md); no response contains `str(exc)`, SQL or a constraint's `DETAIL`. `GUCAuthMiddleware` / `healthz()` get the service's `problem_type_base` (and its `problem_factory`, if it has one), so pgcommon's 401 / 503 problems match the service's own.
1331
1334
 
1332
1335
  **Observability**
1333
1336
  - [ ] Dashboards, alerts and recording rules query `platform_db_*` (grouped by `domain, service, environment, pool`) — Go's reference bundle applies unchanged.
@@ -1462,6 +1465,13 @@ The port matches Go `platform-pgcommon` v2.0.1 everywhere a consumer can observe
1462
1465
  | Forced drain | `forceClose` closes connections | Acquired connections are first closed gracefully for up to 0.5 s — asyncpg sends a cancel request for a running statement — then the pool is terminated | Closing a socket alone leaves the server running the statement, holding its locks, until it next talks to the client |
1463
1466
  | Monitoring bundle | Ships recording rules, alerts, a dashboard and a runbook | Ships none; Go's apply unchanged | The metric families are identical, so one bundle serves both languages |
1464
1467
  | DSN redaction of a percent-encoded `?password=` | `dsnutil` redacts the decoded value only, and escapes only the values as written | Also redacts the value exactly as written, and the escapes of the decoded password | A gap in Go (to be reported upstream, LLD §16 A4): an error quoting such a password as written, or re-escaped, would leak it |
1468
+ | `db.query` span kind | `INTERNAL` until [platform-pgcommon#45](https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon/pull/45) is released, which makes them `CLIENT` | `CLIENT` | The OpenTelemetry database semantic conventions make a database call a client span |
1469
+ | Transaction setup spans | One span per setup statement: the transaction-local GUCs and the transaction timeouts are separate `set_config` statements; an identity-less acquire of a connection a previous caller left an identity on gets a GUC-reset span | One span for the single combined setup statement (marker, GUCs, timeouts), plus a span for the prepared-transaction marker statement when `prepared_xact_guard` is on; no GUC-reset span, because asyncpg's `RESET ALL` on release is untracked | One round trip instead of several; the marker is how the prepared-transaction guard identifies its own transaction (no command tag in asyncpg) |
1470
+ | `exception` span event `exception.type` | The Go type (`*pgconn.PgError`) | The qualified Python class name (`asyncpg.exceptions.UniqueViolationError`) | Language-inherent. The event carries the same two attributes, and `exception.message` and the status are Go's `PgError.Error()` text (`ERROR: … (SQLSTATE 23505)`, never DETAIL / HINT) |
1471
+ | `db.query.text` of internal transaction control | pgx's `begin` / `commit` / `rollback` | asyncpg's `BEGIN;` / `COMMIT` / `ROLLBACK;` | Driver-inherent: each driver sends its own spelling of these statements |
1472
+ | Python-only log events | — | `pgcommon: rollback failed` (cleanup of a failed or cancelled transaction; Go logs only `rollback failed during panic recovery`), `pgcommon: topping the pool up to min_conns failed`, `pgcommon: releasing the connection failed; terminated it`, `pgcommon: closing the pool failed; terminated it`, `pgcommon: reading pg_prepared_xacts failed`, `pgcommon: <sink> raised`, `pgcommon: connection found dead during acquire; replaced` (INFO), `pgcommon: preparing a connection timed out …`, `pgcommon: pool health check failed` | Each reports a failure only the asyncpg port handles itself (pgxpool's health check, release and close cannot fail visibly, and Go has no guarded sinks) |
1473
+ | Migration progress logs | Only the summary line (`migrations: applied up to version N`) | Also an INFO line per migration (`applied migration` / `rolled back migration`, with `version`, `direction`); the summary line is Go's | Visible progress through a long run |
1474
+ | Query-logger hook failure log | `pgcommon: Config.QueryTracer hook panicked; recovered so the pool slot is not leaked` | `pgcommon: Config.query_logger hook raised; ignored so the pool slot is not leaked` | The hook has another name and raises rather than panics |
1465
1475
 
1466
1476
  ---
1467
1477
 
@@ -1481,6 +1491,7 @@ Some judgment calls in this port aren't obvious from the code alone. Each is als
1481
1491
  10. **pgxpool's maintenance is rebuilt on asyncpg internals.** Per-connection lifetime, idle expiry above `min_conns`, the idle ping, the top-up and the slot recovery restore pgxpool's behaviour after failovers. The top-up and slot recovery use private asyncpg attributes, so the dependency is capped (`<0.32`) and every admitted minor is tested.
1482
1492
  11. **Tier 1 metrics only, identical to Go's.** The legacy names were removed with no compatibility period (no release emitting them had been deployed), and one metric vocabulary lets Go's rules, alerts and dashboard serve Python services without a Python-specific bundle.
1483
1493
  12. **Library-internal statements are excluded from query metrics unless they fail, and replaced dead connections are not errors.** Otherwise the SLIs would measure pgcommon (and outages would count each replaced connection) rather than the service's own work.
1494
+ 13. **The FastAPI integration's errors are RFC 9457 problems, built without a new dependency.** The platform answers every HTTP error with `application/problem+json`. pgcommon emits only two problems (the 401 for an invalid identity, the health 503s), so the builder is a small private module (`integrations/_problem.py`), not a dependency on the shared `platform-problem-details` library: that library would otherwise become a hard dependency of the optional extra. Services that use it plug its factory in through `problem_factory`. The settings are constructor and `healthz()` arguments rather than a module global, so two apps in one process cannot change each other's settings. The trace id follows the platform order (`traceparent`, then `x-request-id`); the active OTel span replaces only the "generate one" step. Both problems log at WARN: a failing readiness probe repeats every few seconds, and the outage behind it is already reported by the pool's logs and metrics. Details: [docs/problem-details.md](docs/problem-details.md).
1484
1495
 
1485
1496
  ---
1486
1497
 
@@ -11,6 +11,63 @@ public API.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.4.0] - 2026-10-16
15
+
16
+ Breaking for HTTP clients of the FastAPI integration: `GUCAuthMiddleware`'s 401 and
17
+ `healthz()`'s 503s are now RFC 9457 Problem Details (`application/problem+json`; match on
18
+ `errorCode`, not `detail`). Fixed: a failed statement's span error text now matches Go's,
19
+ without PostgreSQL's DETAIL / HINT lines, and no logged `error` field carries row values.
20
+ Changed: the background health check's failure log has the `pgcommon:` prefix.
21
+
22
+ ### Changed
23
+
24
+ - **BREAKING (HTTP clients that parse the old body):** the two error responses of the FastAPI
25
+ integration are now RFC 9457 Problem Details (`content-type: application/problem+json`).
26
+ The status codes are unchanged. `GUCAuthMiddleware`'s 401 for an invalid identity was
27
+ `{"detail": "invalid identity"}`; it is now a problem with type
28
+ `https://errors.company.com/platform/unauthorized`, `errorCode` `UNAUTHORIZED`, a generic
29
+ `detail` that never echoes the identity, and no `tenantId`. `healthz()`'s 503s were
30
+ `{"detail": "database unavailable"}` / `{"detail": "service overloaded"}`; they are now
31
+ problems with type `https://errors.company.com/platform/service-unavailable` and `errorCode`
32
+ `DATABASE_UNAVAILABLE` / `SERVICE_OVERLOADED`. Every problem has `type`, `title`, `status`,
33
+ `detail`, `instance` (the request path), `traceId`, `errorCode` and `timestamp` (RFC 3339
34
+ UTC), plus `tenantId` only when the request has a trusted tenant context. The `healthz()` 200
35
+ body is unchanged. A client that matched on `detail` should match on `errorCode`. The
36
+ return annotation of the `healthz()` handler widens from `JSONResponse` to `Response`.
37
+ See [docs/problem-details.md](docs/problem-details.md).
38
+ - The background health check's failure log is now `pgcommon: pool health check failed` (was `pool health check failed`, the only pgcommon log message without the `pgcommon:` prefix; Go has no equivalent, pgxpool's health check does not log). Log filters or alerts matching the old text exactly must be updated; the level (WARN) and the `error` field are unchanged.
39
+ - Documented the remaining, acceptable observability differences from Go in `ARCHITECTURE.md` § Divergences from Go, `docs/observability/README.md` and LLD §18.5: span kind (Go's spans become `CLIENT` with platform-pgcommon#45), transaction setup span shape, `exception.type` values, the spelling of internal `BEGIN` / `COMMIT` in `db.query.text`, Python-only log events, per-migration progress lines and the query-logger hook failure wording.
40
+
41
+ ### Added
42
+
43
+ - `GUCAuthMiddleware` and `healthz()` take `problem_type_base` (default
44
+ `DEFAULT_PROBLEM_TYPE_BASE`, `https://errors.company.com`), `problem_factory` (a
45
+ `ProblemFactory`, `(ProblemSpec, Request) -> Response`, which replaces the built-in builder,
46
+ e.g. with the shared `platform-problem-details` factory), `logger` and `service`.
47
+ `ProblemSpec`, `ProblemFactory`, `DEFAULT_PROBLEM_TYPE_BASE` and `PROBLEM_MEDIA_TYPE` are new
48
+ exports of `pgcommon.integrations.fastapi`.
49
+ - `traceId` comes from a valid W3C `traceparent`, else a sanitized `x-request-id` (1 to 128
50
+ characters of `[A-Za-z0-9._:-]`, otherwise ignored), else the active OpenTelemetry span,
51
+ else a new 32-hex id.
52
+ - Each problem is logged once at WARN (`pgcommon: problem response`) with `traceId`, `type`,
53
+ `status`, `errorCode`, `service` (the argument, else the metrics identity's `service`) and
54
+ `tenantId` (when present). The logger is the `logger` argument, else the lifespan pool's
55
+ `Config.logger`. pgcommon never logs the detail, the identity or headers.
56
+ - [docs/problem-details.md](docs/problem-details.md): what the integration emits, the
57
+ configuration, and the recommended mapping of pgcommon's database errors (`is_*`
58
+ helpers, sentinels) to problem types for services.
59
+
60
+ ### Fixed
61
+
62
+ - `OTelQueryRecorder(allow_full_statements=True)`: a failed statement's span status and `exception` event no longer include PostgreSQL's DETAIL / HINT lines, which quote row values (`Key (id)=(1) already exists.`). The status is now Go's `pgconn.PgError.Error()` text — `<Severity>: <Message> (SQLSTATE <code>)`, e.g. `ERROR: duplicate key value violates unique constraint "t_pkey" (SQLSTATE 23505)` — where it was asyncpg's `str()` (message plus DETAIL / HINT, no severity or SQLSTATE). A non-PostgreSQL error keeps its own text (`database error` if it has none). The `exception` event now carries only `exception.type` and `exception.message` (the same text), as Go's `span.RecordError` does: no `exception.stacktrace` or `exception.escaped`. `exception.type` stays the qualified Python class name (Go's is the Go type). Without `allow_full_statements` nothing changes.
63
+ - Log fields no longer carry PostgreSQL's DETAIL / HINT row values either: every `error` field pgcommon logs (health check, rollback and other cleanup failures, releasing or closing a connection, topping the pool up, GUC injection and dead-connection replacement, the `pg_prepared_xacts` lookup, a raising sink) use the same Go `err.Error()` text for a server error (`<Severity>: <Message> (SQLSTATE <code>)`); other errors keep their own text (`repr` where it was before). Shared helper: `pgcommon._internal.sqlstate.error_text`. The `pgcommon-migrate` CLI's `migration error:` line is unchanged and still shows DETAIL: it is operator output, and Go's CLI prints DETAIL too (golang-migrate's `migration failed: <message>, <detail> …`).
64
+
65
+ ## [0.3.1] - 2026-10-15
66
+
67
+ ### Security
68
+
69
+ - `Config` and `ResolvedConfig` no longer print the DSN's password in their `repr` (and so in `str`, tracebacks, debuggers and log lines that format the object): the generated dataclass repr showed the DSN as is. The `dsn` field is now shown through `mask_dsn`; the field value itself is unchanged. Found by platform-eventcommon's review (its `OutboxEnv.pool_config()` hands the `Config` to callers).
70
+
14
71
  ## [0.3.0] - 2026-10-15
15
72
 
16
73
  Brings the port in line with Go `platform-pgcommon` **v2.0.1**: the Tier 1 metrics below, plus
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgcommon
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Opinionated async PostgreSQL access layer for platform services — RLS tenancy, retries, and observability by default.
5
5
  Project-URL: Homepage, https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
6
6
  Project-URL: Repository, https://github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
@@ -32,7 +32,7 @@ The platform's **PostgreSQL access layer for Python services**: an `asyncio` lib
32
32
 
33
33
  **Repository:** `github.com/BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py` (private)
34
34
 
35
- **Package:** `pgcommon` 0.3.0 ([release status](#versioning-and-releases): released 2026-10-15, on [PyPI](https://pypi.org/project/pgcommon/)) · Apache-2.0 · Python ≥ 3.12 (CI runs 3.12, 3.13 and 3.14) · consumed as a dependency, never deployed on its own. It also ships one artefact, the `pgcommon-migrate` CLI, as a container image (`ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`).
35
+ **Package:** `pgcommon` 0.4.0 ([release status](#versioning-and-releases): released 2026-10-16, on [PyPI](https://pypi.org/project/pgcommon/)) · Apache-2.0 · Python ≥ 3.12 (CI runs 3.12, 3.13 and 3.14) · consumed as a dependency, never deployed on its own. It also ships one artefact, the `pgcommon-migrate` CLI, as a container image (`ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`).
36
36
 
37
37
  **Design:** one `src/pgcommon` package — public modules on top of a private `_internal/` layer, with the layer rules enforced by import-linter in CI. See [ARCHITECTURE.md](ARCHITECTURE.md) for design decisions, layer diagrams, and — importantly — how this port's `asyncio`/`contextvars` model differs from the Go original's `context.Context` model. This README is how to **use** it.
38
38
 
@@ -326,8 +326,9 @@ Typical meanings and an example are in [PostgreSQL error helpers](#postgresql-er
326
326
  |---|---|
327
327
  | `create_lifespan(config)` | ASGI lifespan: `Pool.create` at startup (`app.state.pool`), `drain_and_close()` at shutdown |
328
328
  | `get_pool` | `Depends()` provider returning `app.state.pool` |
329
- | `GUCAuthMiddleware(app, identity_extractor=…)` | Validates the extracted `GUCSet`, answers `401` for `IDENTITY_ERRORS`, otherwise runs the request under `with_guc_set` |
330
- | `healthz(*, utilization_threshold=0.9)` | A `/healthz` handler: `503` when unhealthy or at the threshold, else pool utilisation |
329
+ | `GUCAuthMiddleware(app, identity_extractor=…, *, problem_type_base=…, problem_factory=None, logger=None, service="")` | Validates the extracted `GUCSet`, answers an RFC 9457 `401` problem for `IDENTITY_ERRORS`, otherwise runs the request under `with_guc_set` |
330
+ | `healthz(*, utilization_threshold=0.9, problem_type_base=…, problem_factory=None, logger=None, service="")` | A `/healthz` handler: a `503` problem when unhealthy or at the threshold, else pool utilisation |
331
+ | `ProblemSpec` · `ProblemFactory` · `DEFAULT_PROBLEM_TYPE_BASE` · `PROBLEM_MEDIA_TYPE` | The problem a factory receives (`type`, `title`, `status`, `error_code`, `detail`, `instance`, `trace_id`, `tenant_id`), the hook type `(ProblemSpec, Request) -> Response`, `https://errors.company.com`, `application/problem+json` ([docs/problem-details.md](docs/problem-details.md)) |
331
332
 
332
333
  ### `pgcommon-migrate` CLI (image `ghcr.io/bcbp-solutions-fzc-llc/platform-pgcommon-py`)
333
334
 
@@ -423,6 +424,7 @@ platform-pgcommon-py/
423
424
  │ ├── metrics.py · metrics_registry.py # init_with_identity, Identity, pool gauges, OTelQueryRecorder · the governed platform_db_* inventory
424
425
  │ ├── migrate/ # Runner and its errors; cli.py (pgcommon-migrate), __main__.py (python -m pgcommon.migrate)
425
426
  │ ├── integrations/fastapi.py # create_lifespan, get_pool, GUCAuthMiddleware, healthz (extra: pgcommon[fastapi])
427
+ │ ├── integrations/_problem.py # private: RFC 9457 problem body, trace id, log line for fastapi.py
426
428
  │ └── _internal/ # PRIVATE: DSN parsing and redaction, transaction-shape lexer, statement scopes, SQL verb, SQLSTATE, …
427
429
  ├── tests/ # unit (no Docker) · integration (Postgres) · e2e (Postgres + PgBouncer), via testcontainers; fixtures/
428
430
  ├── migrations/ # An example 0001 migration pair, for trying the CLI locally (not in the wheel)
@@ -708,8 +710,25 @@ COMMIT / ROLLBACK — GUCs automatically cleared; connection safely returned to
708
710
  #### Wiring (recommended — contextvars-based)
709
711
 
710
712
  ```python
713
+ from fastapi.responses import JSONResponse
714
+
711
715
  from pgcommon import IDENTITY_ERRORS, Config, GUCSet, Pool, guc_set_from_context, with_guc_set
712
716
 
717
+
718
+ # Errors are RFC 9457 Problem Details (docs/problem-details.md). Your service's problem builder
719
+ # (platform-problem-details) replaces this stand-in; it also adds traceId and timestamp.
720
+ def problem(request, error: str, title: str, status: int, error_code: str, detail: str):
721
+ body = {
722
+ "type": f"https://errors.company.com/platform/{error}",
723
+ "title": title,
724
+ "status": status,
725
+ "detail": detail,
726
+ "instance": request.url.path,
727
+ "errorCode": error_code,
728
+ }
729
+ return JSONResponse(body, status_code=status, media_type="application/problem+json")
730
+
731
+
713
732
  # 1. Wire once at startup:
714
733
  pool = await Pool.create(Config(dsn=dsn, guc_provider=guc_set_from_context))
715
734
 
@@ -723,7 +742,14 @@ async def auth_middleware(request, call_next):
723
742
  try:
724
743
  guc_set.validate()
725
744
  except IDENTITY_ERRORS: # every GUCSet.validate() error: a caller error, not a DB failure
726
- return Response("invalid identity", status_code=401)
745
+ return problem(
746
+ request,
747
+ "unauthorized",
748
+ "Unauthorized",
749
+ 401,
750
+ "UNAUTHORIZED",
751
+ "The request identity is invalid.",
752
+ )
727
753
  with with_guc_set(guc_set):
728
754
  return await call_next(request)
729
755
 
@@ -792,6 +818,37 @@ async def list_orders(pool=Depends(get_pool)):
792
818
 
793
819
  `GUCAuthMiddleware` validates the extracted identity and responds `401` for any of `IDENTITY_ERRORS`; only that validation is guarded, so an identity error raised later by a handler (or by the pool, for a `GUCSet` a handler set itself) is not turned into this request's 401.
794
820
 
821
+ #### Error responses (RFC 9457 Problem Details)
822
+
823
+ The 401 and `healthz()`'s two 503s are `application/problem+json` problems. The status codes are the same as in 0.3.x; the bodies changed:
824
+
825
+ ```json
826
+ {
827
+ "type": "https://errors.company.com/platform/unauthorized",
828
+ "title": "Unauthorized",
829
+ "status": 401,
830
+ "detail": "The request identity is invalid.",
831
+ "instance": "/orders/7",
832
+ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
833
+ "errorCode": "UNAUTHORIZED",
834
+ "timestamp": "2026-10-15T10:45:22Z"
835
+ }
836
+ ```
837
+
838
+ The 503s have type `https://errors.company.com/platform/service-unavailable`, title `Service Unavailable` and `errorCode` `DATABASE_UNAVAILABLE` (`The database is unavailable.`) or `SERVICE_OVERLOADED` (`The service is overloaded.`). `detail` is always a fixed sentence: it never contains exception text, SQL or identity values. The 401 never has a `tenantId`; a 503 has one only when the request carries a tenant `GUCSet`. `traceId` is the `traceparent` trace-id, else a sanitized `x-request-id`, else the active OpenTelemetry span's trace id, else a new 32-hex id. Each problem is logged once at WARN (`pgcommon: problem response`) through `logger=`, else the lifespan pool's `Config.logger`.
839
+
840
+ ```python
841
+ app.add_middleware(
842
+ GUCAuthMiddleware,
843
+ identity_extractor=extract_identity,
844
+ problem_type_base="https://errors.example.com", # default https://errors.company.com
845
+ problem_factory=my_problem_factory, # optional: (ProblemSpec, Request) -> Response
846
+ )
847
+ app.add_api_route("/healthz", healthz(problem_factory=my_problem_factory), methods=["GET"])
848
+ ```
849
+
850
+ Pass `problem_factory` to build both responses with the service's own problem library (e.g. the shared `platform-problem-details`). For the full contract, and for the **recommended mapping of pgcommon's database errors** (`is_unique_violation` → 409 conflict, `AcquireTimeoutError` → 503, …) to problems in your own handlers, see [docs/problem-details.md](docs/problem-details.md).
851
+
795
852
  > **contextvars and request isolation.** Go threads an explicit `context.Context` through every call; Python instead relies on `contextvars.ContextVar`, which is copied per `asyncio.Task` at task-creation time. Each inbound ASGI request runs on its own task, so `GUCAuthMiddleware`'s `with_guc_set(...)` — entered around `call_next(request)` after validation and exited (resetting the token) in a `finally` block — cannot leak into a concurrently-running request. The failure mode to avoid: never call the underlying `ContextVar.set()` without capturing and later `reset()`-ing its token in the same middleware invocation. See `pgcommon/integrations/fastapi.py`'s module docstring and ARCHITECTURE.md for the full explanation.
796
853
 
797
854
  > **Work started from a request runs as that request's identity.** A task copies the context when it is created, so Starlette `BackgroundTasks` and `asyncio.create_task(...)` called from a handler run as the requesting user and tenant even after the response — right for per-request work (its data stays scoped to the tenant that asked). The hazard is a **long-lived worker started lazily inside a request** (cache refresher, queue consumer, "start on first use" singleton): it acts as the *first* requester's tenant forever. Start such workers from the app's lifespan (outside any request, so no `GUCSet`), or start them in a fresh context — `asyncio.create_task(worker(), context=contextvars.Context())` — or give the worker an explicit identity with `with with_guc_set(GUCSet()):` (an empty `GUCSet`: no GUC injection).
@@ -982,17 +1039,24 @@ A ping must acquire a connection, so on a fully checked-out pool it would queue
982
1039
 
983
1040
  ```python
984
1041
  @app.get("/healthz")
985
- async def healthz(pool: Pool = Depends(get_pool)):
1042
+ async def healthz(request: Request, pool: Pool = Depends(get_pool)):
986
1043
  status = await pool.health()
987
1044
  if not status.healthy:
988
- return Response("database unavailable", status_code=503)
1045
+ return problem(
1046
+ request,
1047
+ "service-unavailable",
1048
+ "Service Unavailable",
1049
+ 503,
1050
+ "DATABASE_UNAVAILABLE",
1051
+ "The database is unavailable.",
1052
+ )
989
1053
  return {
990
1054
  "conns": f"{status.acquired_conns}/{status.max_conns}",
991
1055
  "utilization": status.utilization,
992
1056
  }
993
1057
  ```
994
1058
 
995
- (`pgcommon.integrations.fastapi.healthz()` builds exactly this handler, with utilization-based shedding included — see [FastAPI integration](#fastapi-integration).)
1059
+ (`problem()` is the stand-in from [Wiring](#wiring-recommended--contextvars-based). `pgcommon.integrations.fastapi.healthz()` builds exactly this handler, with utilization-based shedding, `traceId` and `timestamp` included — see [FastAPI integration](#fastapi-integration).)
996
1060
 
997
1061
  #### Utilization-based load shedding
998
1062
 
@@ -1002,12 +1066,26 @@ async def db_health_middleware(request, call_next):
1002
1066
 
1003
1067
  # Fail fast when the database is unreachable.
1004
1068
  if not status.healthy:
1005
- return Response("database unavailable", status_code=503)
1069
+ return problem(
1070
+ request,
1071
+ "service-unavailable",
1072
+ "Service Unavailable",
1073
+ 503,
1074
+ "DATABASE_UNAVAILABLE",
1075
+ "The database is unavailable.",
1076
+ )
1006
1077
 
1007
1078
  # Shed load when the pool is 90%+ saturated to protect the last connections
1008
1079
  # for in-flight requests — prevents cascading timeouts under traffic spikes.
1009
1080
  if status.utilization >= 0.9:
1010
- return Response("service overloaded", status_code=503)
1081
+ return problem(
1082
+ request,
1083
+ "service-unavailable",
1084
+ "Service Unavailable",
1085
+ 503,
1086
+ "SERVICE_OVERLOADED",
1087
+ "The service is overloaded.",
1088
+ )
1011
1089
 
1012
1090
  return await call_next(request)
1013
1091
  ```
@@ -1081,6 +1159,8 @@ async def create_user(pool, email):
1081
1159
  | `sql_state` | — | The raw SQLSTATE (e.g. `"23505"`), or `""`; for codes no helper covers, or logging |
1082
1160
  | `is_pool_closed` | `PoolClosedError`, asyncpg's closed-pool error, or a query a timed-out `drain_and_close` interrupted | Shutting down: final, do not retry |
1083
1161
 
1162
+ For the HTTP side, [docs/problem-details.md](docs/problem-details.md) gives the recommended RFC 9457 problem type for each helper. Never return `str(exc)`: pgcommon's exception text can carry SQL and row values (a constraint's `DETAIL`); keep `sql_state` / `constraint_name` for server-side logs.
1163
+
1084
1164
  `is_pg_error`, `sql_state` and the class helpers ignore asyncpg's client-side errors that inherit a SQLSTATE but were never sent by a server (e.g. `ConnectionDoesNotExistError`, raised when the connection is lost mid-query), as Go's do for errors that are not a `*pgconn.PgError`.
1085
1165
 
1086
1166
  ### Slow-query logging
@@ -2000,7 +2080,7 @@ Each statement produces a `db.query` span (kind `CLIENT`) with the stable OpenTe
2000
2080
  | `server.address` / `server.port` | The server the connection is attached to |
2001
2081
  | `db.query.text` | The full SQL — **only** with `Config.allow_full_statements = True` |
2002
2082
 
2003
- A failed statement records only its SQLSTATE — `db.response.status_code`, `error.type` (the code), `db.response.severity` and the status `SQLSTATE <code>` — because PostgreSQL error messages quote input values. A non-PostgreSQL error gets `error.type` `context_canceled` (cancellation), `context_deadline_exceeded` (timeout) or `_OTHER`, and the status `database error`. The error message and an exception event are added only with `allow_full_statements`.
2083
+ A failed statement records only its SQLSTATE — `db.response.status_code`, `error.type` (the code), `db.response.severity` and the status `SQLSTATE <code>` — because PostgreSQL error messages quote input values. A non-PostgreSQL error gets `error.type` `context_canceled` (cancellation), `context_deadline_exceeded` (timeout) or `_OTHER`, and the status `database error`. The error message and an exception event are added only with `allow_full_statements`: the status is then Go's text, `ERROR: <message> (SQLSTATE <code>)` for a PostgreSQL error (never its DETAIL or HINT lines, which quote row values), and the event carries `exception.type` and `exception.message` only, with no stack trace.
2004
2084
 
2005
2085
  > **SQL privacy:** by default spans carry only the SQL verb and the SQLSTATE, to prevent PII leakage to tracing backends. Set `Config.allow_full_statements = True` to add the full SQL and error messages — only do this when query text contains no sensitive data.
2006
2086
 
@@ -2040,8 +2120,8 @@ pgcommon itself is released to PyPI, and services adopt it by bumping their pin;
2040
2120
  Pin an exact version in production ([VERSIONING.md § Consume a release](VERSIONING.md#consume-a-release) has the pin styles):
2041
2121
 
2042
2122
  ```bash
2043
- pip install "pgcommon==0.3.0" # or "pgcommon[fastapi]==0.3.0" / "pgcommon[structlog]==0.3.0"
2044
- uv add "pgcommon==0.3.0"
2123
+ pip install "pgcommon==0.4.0" # or "pgcommon[fastapi]==0.4.0" / "pgcommon[structlog]==0.4.0"
2124
+ uv add "pgcommon==0.4.0"
2045
2125
  ```
2046
2126
 
2047
2127
  The files on PyPI are byte-identical to the GitHub Release's wheel and sdist: the release builds them once and uploads the same files to both.
@@ -2059,7 +2139,7 @@ Two-stage `Dockerfile`. The build stage (`python:3.14-slim` with uv `0.11.32`, b
2059
2139
  Needs [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) ≥ 2.4. A tag-triggered release is signed by `release.yml@refs/tags/<tag>`; a re-run dispatched from `main` by `release.yml@refs/heads/main`, which the regular expression below also accepts.
2060
2140
 
2061
2141
  ```bash
2062
- TAG=v0.3.0 # the release to verify
2142
+ TAG=v0.4.0 # the release to verify
2063
2143
  REPO=BCBP-SOLUTIONS-FZC-LLC/platform-pgcommon-py
2064
2144
  IDENTITY="^https://github\.com/${REPO}/\.github/workflows/release\.yml@refs/(tags/${TAG}|heads/main)$"
2065
2145
 
@@ -2085,7 +2165,7 @@ Run migrations as a separate init container or pre-deploy job, to completion, ov
2085
2165
 
2086
2166
  ### Versioning and releases
2087
2167
 
2088
- **Release status:** 0.3.0 was released on 2026-10-15 (`CHANGELOG.md` `[0.3.0]`): Go v2.0.1 parity and production hardening, breaking against 0.2.0 — read the CHANGELOG's Upgrade notes before upgrading. `platform-eventcommon` supports it from its `pgcommon>=0.2.0,<0.4.0` range, and the cross-language interop harness checks the pair at `platform-interop-tests` `9bd44ca`.
2168
+ **Release status:** 0.4.0 was released on 2026-10-16 (`CHANGELOG.md` `[0.4.0]`). It is breaking for HTTP clients of the FastAPI integration: `GUCAuthMiddleware`'s 401 and `healthz()`'s 503s are RFC 9457 Problem Details ([docs/problem-details.md](docs/problem-details.md)); a client that matched on `detail` should match on `errorCode`. It also fixes span error text to match Go's (no DETAIL / HINT row values) and keeps row values out of logged errors. Before it: 0.3.1 (2026-10-15, the `Config` repr masks the DSN password) and 0.3.0 (2026-10-15, Go v2.0.1 parity and production hardening, breaking against 0.2.0 — read its Upgrade notes before upgrading from 0.2.x). `platform-eventcommon` admits 0.4.0 with `pgcommon>=0.2.0,<0.5.0`.
2089
2169
 
2090
2170
  **Go parity:** this port tracks Go `platform-pgcommon` **v2.0.1** — the same Tier 1 metrics, configuration, transaction, error and migration behaviour, and the same migrate CLI, except for the documented [divergences](ARCHITECTURE.md#divergences-from-go). The Python package keeps its own version line (`0.x`); the CHANGELOG names the Go version each change corresponds to.
2091
2171
 
@@ -2208,7 +2288,7 @@ MIGRATIONS_DIR=/migrations
2208
2288
 
2209
2289
  | Consumer | pgcommon pin | Uses |
2210
2290
  |---|---|---|
2211
- | `platform-eventcommon` (Python port of `platform-events`) | `>=0.2.0,<0.3.0` — moving to 0.3.0 needs a coordinated change ([Upgrading from 0.2.0](#upgrading-from-020)) | `Pool` and `run_in_tx` for its outbox, inbox and DLQ stores; `with_guc_set` so message handlers run under RLS; `MigrationDirtyError` for its own schema bookkeeping |
2291
+ | `platform-eventcommon` (Python port of `platform-events`) | `>=0.2.0,<0.5.0` on its `main` | `Pool` and `run_in_tx` for its outbox, inbox and DLQ stores; `with_guc_set` so message handlers run under RLS; `MigrationDirtyError` for its own schema bookkeeping |
2212
2292
  | `platform-interop-tests` (Python probe) | — | Cross-language compatibility with the Go library (CI's `interop` job) |
2213
2293
  | FastAPI platform services | their own | Pool, RLS injection, transactions, error helpers, metrics, `pgcommon.integrations.fastapi` |
2214
2294
 
@@ -2290,6 +2370,7 @@ BCBP Solutions FZC LLC — internal platform shared library.
2290
2370
  | [ARCHITECTURE.md](./ARCHITECTURE.md) | Design decisions, layer diagrams, and the asyncio/contextvars translation notes |
2291
2371
  | [docs/MigrationGuide.md](./docs/MigrationGuide.md) | Concept-by-concept mapping from the Go `platform-pgcommon` to this port |
2292
2372
  | [docs/lld/platform-lld-pgcommon-py.md](./docs/lld/platform-lld-pgcommon-py.md) | Low-level design: module layout, API contract, GUC contract, acquire / transaction / migration algorithms, failure handling, telemetry, invariants and open questions |
2373
+ | [docs/problem-details.md](./docs/problem-details.md) | RFC 9457 Problem Details: the FastAPI integration's 401 / 503 problems, the type base and factory hook, and the recommended mapping of pgcommon's database errors to problem types |
2293
2374
  | [docs/architecture/README.md](./docs/architecture/README.md) | The standalone Mermaid sources of every ARCHITECTURE.md diagram, and how to keep them in sync |
2294
2375
  | [docs/observability/README.md](./docs/observability/README.md) | The Enterprise Platform Observability Standard as implemented here: classification, wiring, divergences from Go, alerts and dashboard |
2295
2376
  | [docs/observability/metrics-registry.md](./docs/observability/metrics-registry.md) | Generated metric inventory and ratification packets |