python-neva 4.0.0__tar.gz → 5.0.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. {python_neva-4.0.0 → python_neva-5.0.0}/.gitlab-ci.yml +9 -0
  2. {python_neva-4.0.0 → python_neva-5.0.0}/CHANGELOG.md +30 -0
  3. {python_neva-4.0.0 → python_neva-5.0.0}/CLAUDE.md +12 -1
  4. {python_neva-4.0.0 → python_neva-5.0.0}/PKG-INFO +47 -6
  5. {python_neva-4.0.0 → python_neva-5.0.0}/README.md +44 -3
  6. {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/manager.py +0 -2
  7. python_neva-5.0.0/neva/guidelines/__init__.py +6 -0
  8. python_neva-5.0.0/neva/guidelines/fragments/configuration.md +108 -0
  9. python_neva-5.0.0/neva/guidelines/fragments/database-transactions.md +125 -0
  10. python_neva-5.0.0/neva/guidelines/fragments/events.md +150 -0
  11. python_neva-5.0.0/neva/guidelines/fragments/observability.md +116 -0
  12. python_neva-5.0.0/neva/guidelines/fragments/result-option.md +74 -0
  13. python_neva-5.0.0/neva/guidelines/fragments/service-providers.md +146 -0
  14. python_neva-5.0.0/neva/guidelines/fragments/testing.md +133 -0
  15. python_neva-5.0.0/neva/guidelines/manifest.py +26 -0
  16. python_neva-5.0.0/neva/obs/__init__.py +19 -0
  17. python_neva-5.0.0/neva/obs/config.py +37 -0
  18. python_neva-5.0.0/neva/obs/logging/__init__.py +22 -0
  19. python_neva-5.0.0/neva/obs/logging/channels.py +310 -0
  20. python_neva-5.0.0/neva/obs/logging/contracts.py +40 -0
  21. python_neva-5.0.0/neva/obs/logging/manager.py +196 -0
  22. python_neva-5.0.0/neva/obs/logging/provider.py +36 -0
  23. python_neva-5.0.0/neva/obs/logging/resolver.py +138 -0
  24. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/log.pyi +40 -0
  25. python_neva-5.0.0/neva/testing/py.typed +0 -0
  26. {python_neva-4.0.0 → python_neva-5.0.0}/pyproject.toml +5 -3
  27. {python_neva-4.0.0 → python_neva-5.0.0}/ruff.toml +2 -2
  28. python_neva-5.0.0/tests/arch/test_register_resolution.py +108 -0
  29. python_neva-5.0.0/tests/obs/__init__.py +0 -0
  30. python_neva-5.0.0/tests/obs/conftest.py +133 -0
  31. python_neva-5.0.0/tests/obs/test_channels.py +213 -0
  32. python_neva-5.0.0/tests/obs/test_facade.py +90 -0
  33. python_neva-5.0.0/tests/obs/test_manager.py +160 -0
  34. python_neva-5.0.0/tests/obs/test_provider_hook.py +107 -0
  35. python_neva-5.0.0/tests/obs/test_resolver.py +127 -0
  36. python_neva-5.0.0/tests/support/__init__.py +0 -0
  37. python_neva-5.0.0/tests/support/test_results.py +310 -0
  38. {python_neva-4.0.0 → python_neva-5.0.0}/uv.lock +20 -62
  39. python_neva-4.0.0/neva/obs/__init__.py +0 -9
  40. python_neva-4.0.0/neva/obs/instrumentation/__init__.py +0 -1
  41. python_neva-4.0.0/neva/obs/instrumentation/sqlalchemy.py +0 -15
  42. python_neva-4.0.0/neva/obs/logging/__init__.py +0 -10
  43. python_neva-4.0.0/neva/obs/logging/manager.py +0 -90
  44. python_neva-4.0.0/neva/obs/logging/provider.py +0 -26
  45. python_neva-4.0.0/wiki/architecture/KNOWN-DISCREPANCIES.md +0 -41
  46. python_neva-4.0.0/wiki/architecture/configuration.md +0 -340
  47. python_neva-4.0.0/wiki/architecture/database-and-transactions.md +0 -407
  48. python_neva-4.0.0/wiki/architecture/dependency-injection.md +0 -584
  49. python_neva-4.0.0/wiki/architecture/events.md +0 -424
  50. python_neva-4.0.0/wiki/architecture/result-option.md +0 -296
  51. python_neva-4.0.0/wiki/architecture/testing.md +0 -425
  52. {python_neva-4.0.0 → python_neva-5.0.0}/.claude/settings.local.json +0 -0
  53. {python_neva-4.0.0 → python_neva-5.0.0}/.envrc +0 -0
  54. {python_neva-4.0.0 → python_neva-5.0.0}/.gitignore +0 -0
  55. {python_neva-4.0.0 → python_neva-5.0.0}/.pre-commit-config.yaml +0 -0
  56. {python_neva-4.0.0 → python_neva-5.0.0}/.python-version +0 -0
  57. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/__init__.py +0 -0
  58. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/application.py +0 -0
  59. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/config.py +0 -0
  60. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/facade.py +0 -0
  61. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/integrations/__init__.py +0 -0
  62. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/integrations/faststream.py +0 -0
  63. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/markers.py +0 -0
  64. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/py.typed +0 -0
  65. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/scopes.py +0 -0
  66. {python_neva-4.0.0 → python_neva-5.0.0}/neva/arch/service_provider.py +0 -0
  67. {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/__init__.py +0 -0
  68. {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/base_providers.py +0 -0
  69. {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/loader.py +0 -0
  70. {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/py.typed +0 -0
  71. {python_neva-4.0.0 → python_neva-5.0.0}/neva/config/repository.py +0 -0
  72. {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/__init__.py +0 -0
  73. {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/config.py +0 -0
  74. {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/connection.py +0 -0
  75. {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/provider.py +0 -0
  76. {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/py.typed +0 -0
  77. {python_neva-4.0.0 → python_neva-5.0.0}/neva/database/transaction.py +0 -0
  78. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/__init__.py +0 -0
  79. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/__init__.py +0 -0
  80. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/dispatcher.py +0 -0
  81. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/event.py +0 -0
  82. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/handler.py +0 -0
  83. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/contracts/listener.py +0 -0
  84. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/dispatcher.py +0 -0
  85. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/event.py +0 -0
  86. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/event_registry.py +0 -0
  87. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/listener.py +0 -0
  88. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/policy.py +0 -0
  89. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/provider.py +0 -0
  90. {python_neva-4.0.0 → python_neva-5.0.0}/neva/events/py.typed +0 -0
  91. {python_neva-4.0.0/neva/obs → python_neva-5.0.0/neva/guidelines}/py.typed +0 -0
  92. {python_neva-4.0.0/neva/polyfactory → python_neva-5.0.0/neva/obs}/py.typed +0 -0
  93. {python_neva-4.0.0 → python_neva-5.0.0}/neva/polyfactory/__init__.py +0 -0
  94. {python_neva-4.0.0 → python_neva-5.0.0}/neva/polyfactory/factories.py +0 -0
  95. {python_neva-4.0.0 → python_neva-5.0.0}/neva/polyfactory/persistence.py +0 -0
  96. {python_neva-4.0.0/neva/security → python_neva-5.0.0/neva/polyfactory}/py.typed +0 -0
  97. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/__init__.py +0 -0
  98. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/encryption/__init__.py +0 -0
  99. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/encryption/encrypter.py +0 -0
  100. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/encryption/protocol.py +0 -0
  101. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/__init__.py +0 -0
  102. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/config.py +0 -0
  103. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hash_manager.py +0 -0
  104. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/__init__.py +0 -0
  105. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/argon2.py +0 -0
  106. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/bcrypt.py +0 -0
  107. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/hashing/hashers/protocol.py +0 -0
  108. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/provider.py +0 -0
  109. {python_neva-4.0.0/neva/support → python_neva-5.0.0/neva/security}/py.typed +0 -0
  110. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/__init__.py +0 -0
  111. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/generate_token.py +0 -0
  112. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/hash_token.py +0 -0
  113. {python_neva-4.0.0 → python_neva-5.0.0}/neva/security/tokens/verify_token.py +0 -0
  114. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/__init__.py +0 -0
  115. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/accessors.py +0 -0
  116. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/__init__.py +0 -0
  117. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/app.py +0 -0
  118. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/app.pyi +0 -0
  119. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/config.py +0 -0
  120. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/config.pyi +0 -0
  121. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/crypt.py +0 -0
  122. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/crypt.pyi +0 -0
  123. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/db.py +0 -0
  124. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/db.pyi +0 -0
  125. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/event.py +0 -0
  126. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/event.pyi +0 -0
  127. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/hash.py +0 -0
  128. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/hash.pyi +0 -0
  129. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/facade/log.py +0 -0
  130. {python_neva-4.0.0/neva/testing → python_neva-5.0.0/neva/support}/py.typed +0 -0
  131. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/results.py +0 -0
  132. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/strategy.py +0 -0
  133. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/strconv.py +0 -0
  134. {python_neva-4.0.0 → python_neva-5.0.0}/neva/support/time.py +0 -0
  135. {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/__init__.py +0 -0
  136. {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/fakes.py +0 -0
  137. {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/fixtures.py +0 -0
  138. {python_neva-4.0.0 → python_neva-5.0.0}/neva/testing/test_case.py +0 -0
  139. {python_neva-4.0.0 → python_neva-5.0.0}/scripts/retag-with-changelog.sh +0 -0
  140. {python_neva-4.0.0 → python_neva-5.0.0}/tests/__init__.py +0 -0
  141. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/__init__.py +0 -0
  142. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_cache.py +0 -0
  143. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_config_shapes.py +0 -0
  144. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_context.py +0 -0
  145. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_extends.py +0 -0
  146. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_facade_root_nesting.py +0 -0
  147. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_lifetimes.py +0 -0
  148. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_registration.py +0 -0
  149. {python_neva-4.0.0 → python_neva-5.0.0}/tests/arch/test_scope.py +0 -0
  150. {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/__init__.py +0 -0
  151. {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/test_config_path_resolution.py +0 -0
  152. {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/test_loader.py +0 -0
  153. {python_neva-4.0.0 → python_neva-5.0.0}/tests/config/test_repository.py +0 -0
  154. {python_neva-4.0.0 → python_neva-5.0.0}/tests/conftest.py +0 -0
  155. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/__init__.py +0 -0
  156. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_connection_manager.py +0 -0
  157. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_database_manager.py +0 -0
  158. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_detached_lifespan.py +0 -0
  159. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_edge_cases.py +0 -0
  160. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_multi_connection.py +0 -0
  161. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_sqlalchemy_integration.py +0 -0
  162. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction.py +0 -0
  163. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction_callbacks.py +0 -0
  164. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction_context.py +0 -0
  165. {python_neva-4.0.0 → python_neva-5.0.0}/tests/database/test_transaction_registry.py +0 -0
  166. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/__init__.py +0 -0
  167. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/conftest.py +0 -0
  168. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_before_dispatch.py +0 -0
  169. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_binding.py +0 -0
  170. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_deferred.py +0 -0
  171. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_dispatch.py +0 -0
  172. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_event.py +0 -0
  173. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_function_listener.py +0 -0
  174. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_immediate.py +0 -0
  175. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_listen_on_parent_class.py +0 -0
  176. {python_neva-4.0.0 → python_neva-5.0.0}/tests/events/test_listener_wiring.py +0 -0
  177. {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/__init__.py +0 -0
  178. {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/test_encrypter.py +0 -0
  179. {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/test_hash_manager.py +0 -0
  180. {python_neva-4.0.0 → python_neva-5.0.0}/tests/security/test_tokens.py +0 -0
  181. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/__init__.py +0 -0
  182. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_application_reuse.py +0 -0
  183. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_create_config_migration.py +0 -0
  184. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_event_fake.py +0 -0
  185. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_facade_restore.py +0 -0
  186. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_fixtures.py +0 -0
  187. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_refresh_database.py +0 -0
  188. {python_neva-4.0.0 → python_neva-5.0.0}/tests/testing/test_test_case.py +0 -0
@@ -48,6 +48,15 @@ pyrefly:
48
48
  script:
49
49
  - uv run --frozen pyrefly check
50
50
 
51
+ # The fragments shipped in the wheel cite the tests that prove their claims.
52
+ # Every cited path must still resolve, so a behaviour change that leaves the
53
+ # guidance behind fails here rather than reaching an agent as a wrong answer.
54
+ guidelines:
55
+ extends: [.uv_base, .default_rules]
56
+ stage: code_qa
57
+ script:
58
+ - uv run --frozen neva-boost check neva/guidelines/fragments
59
+
51
60
  ##### Test #####
52
61
  pytest:
53
62
  extends: [.uv_base, .default_rules]
@@ -1,3 +1,33 @@
1
+ ## 5.0.0 (2026-09-01)
2
+
3
+ ### 💥 Boom
4
+
5
+ - **obs**: drop OpenTelemetry and pyinstrument from the core
6
+
7
+ ### ✨ Features
8
+
9
+ - **obs**: resolve logging through named channels
10
+ - **deps**: offer neva-boost through the boost extra
11
+
12
+ ### 💚👷 CI & Build
13
+
14
+ - **guidelines**: fail when a fragment cites a test that is gone
15
+
16
+ ### 📝💡 Documentation
17
+
18
+ - **guidelines**: rule out resolving from the container in register()
19
+ - **guidelines**: cover logging channels, and where OTel now lives
20
+
21
+ ## 4.1.0 (2026-08-31)
22
+
23
+ ### ✅🤡🧪 Tests
24
+
25
+ - **support**: add tests for results and options
26
+
27
+ ### 📝💡 Documentation
28
+
29
+ - **guidelines**: ship agent guidelines in the wheel
30
+
1
31
  ## 4.0.0 (2026-08-28)
2
32
 
3
33
  ### 💥 Boom
@@ -138,4 +138,15 @@ rendered changelog. See `README.md` for the full flow and the plugin list.
138
138
 
139
139
  ## Documentation
140
140
 
141
- - Architecture guides: `wiki/architecture/`
141
+ Agent guidelines live in `neva/guidelines/fragments/` and ship inside the wheel, so a
142
+ consuming project gets the guidance for the version it actually installed. They are
143
+ discovered by `neva-boost` through the `neva.boost` entry point (`neva/guidelines/manifest.py`).
144
+
145
+ - One fragment per topic, with frontmatter: `id`, `title`, `requires` (a version
146
+ specifier), `triggers`, `priority`, `verified_by`.
147
+ - **`verified_by` lists the tests that prove the fragment's claims.** Every path must
148
+ resolve. When you change behaviour, update the fragment in the same commit as the code
149
+ and the test — that pairing is what keeps the guidance honest.
150
+ - Terse and rule-shaped, not narrative: this text is loaded into an agent's context.
151
+ - Nothing here may describe another package. `neva-fastapi` and friends ship their own
152
+ fragments; a cross-package claim made here cannot be verified or maintained.
@@ -1,21 +1,21 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-neva
3
- Version: 4.0.0
3
+ Version: 5.0.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: aiosqlite>=0.20.0
7
7
  Requires-Dist: asyncpg>=0.30.0
8
8
  Requires-Dist: cryptography>=46.0.3
9
9
  Requires-Dist: dishka>=1.10.0
10
- Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.62b0
11
10
  Requires-Dist: pwdlib[argon2,bcrypt]>=0.3.0
12
11
  Requires-Dist: pydantic>=2.7
13
- Requires-Dist: pyinstrument>=5.1.1
14
12
  Requires-Dist: sqlalchemy[asyncio]>=2.0.0
15
13
  Requires-Dist: structlog>=25.5.0
16
14
  Requires-Dist: typing-extensions>=4.13
17
15
  Provides-Extra: asgi
18
16
  Requires-Dist: neva-asgi>=0.1.1; extra == 'asgi'
17
+ Provides-Extra: boost
18
+ Requires-Dist: neva-boost>=0.2.1; extra == 'boost'
19
19
  Provides-Extra: fastapi
20
20
  Requires-Dist: neva-fastapi>=1.1.1; extra == 'fastapi'
21
21
  Provides-Extra: faststream
@@ -47,17 +47,59 @@ independent repo, independently versioned and published.
47
47
 
48
48
  | Package | Repo / directory | Role | Status |
49
49
  | ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
50
- | `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
50
+ | `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, structured logging, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
51
51
  | `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
52
52
  | `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
53
53
  | `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
54
54
  | `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
55
+ | `neva-otel` | `neva-otel/` | **OpenTelemetry** — SDK wiring, tracer and meter providers, exporters, samplers and instrumentors. Kept out of the core so nothing is committed to an observability backend. | planned |
56
+ | `neva-boost` | `neva-boost/` | **Agent guidelines** — composes each installed package's versioned guideline fragments into a project's agent configuration (Claude Code skills or `AGENTS.md`). Dev-time tooling; pulled in via the `python-neva[boost]` extra. | published ([PyPI](https://pypi.org/project/neva-boost/)) |
55
57
  | `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
56
58
 
57
59
  > When working across packages locally, each repo can be cloned as a sibling
58
60
  > directory; integration packages depend on `python-neva` from PyPI (or an
59
61
  > editable local path during development).
60
62
 
63
+ ## Logging
64
+
65
+ Logging is a set of named channels, each with its own driver, level and output —
66
+ Laravel's `Log`, configured from one `config/obs.py` namespace.
67
+
68
+ ```python
69
+ config = {
70
+ "logging": {
71
+ "default": "stdout",
72
+ "channels": {
73
+ "stdout": {"driver": "console", "level": "DEBUG"},
74
+ "json": {"driver": "json", "stream": "stderr", "level": "INFO"},
75
+ "audit": {"driver": "file", "path": "var/audit.log"},
76
+ "both": {"driver": "stack", "channels": ["json", "audit"]},
77
+ },
78
+ },
79
+ }
80
+ ```
81
+
82
+ ```python
83
+ from neva.support.facade import Log
84
+
85
+ Log.info("order placed", order_id=42) # default channel
86
+ Log.channel("audit").warning("role granted", actor="root")
87
+ Log.bind(tenant="acme") # every channel, this context
88
+ ```
89
+
90
+ Drivers are `console`, `json`, `file`, `stack` and `null`. With no `config/obs.py`
91
+ at all, logging still works: a console channel on stdout at `DEBUG`.
92
+
93
+ Each channel carries its own structlog processor chain rather than going through
94
+ the process-global `structlog.configure()`, so one application's renderer cannot
95
+ leak into another in the same interpreter. `LogManager.processor(fn)` lets a
96
+ plugin add a processor to every chain — the seam `neva-otel` uses to put the
97
+ current span's ids on every record.
98
+
99
+ **Tracing and metrics are deliberately not here.** They live in `neva-otel`, so
100
+ the core carries no OpenTelemetry dependency and commits no application to an
101
+ observability backend.
102
+
61
103
  ## Develop
62
104
 
63
105
  ```bash
@@ -103,10 +145,9 @@ rewrite the new tag with the rendered changelog as its annotation.
103
145
  ## Feature ideas
104
146
 
105
147
  - Feature-parity FastStream integration (now scaffolded as `neva-faststream`)
106
- - Finish cleaning up the core package (moving any remaining ASGI-dependent code into the appropriate integration packages)
107
148
  - Improved router registration (auto-discovery OR provider-based? both?)
108
149
  - Improved security tooling (performance improvements, better defaults, etc.)
109
- - Better OpenTelemetry integration
150
+ - OpenTelemetry as a plugin (`neva-otel`) — the core keeps logging and takes no OTel dependency
110
151
  - Improved factory module (based on Polyfactory)
111
152
  - Queue/Jobs system
112
153
  - CLI integration
@@ -18,17 +18,59 @@ independent repo, independently versioned and published.
18
18
 
19
19
  | Package | Repo / directory | Role | Status |
20
20
  | ----------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------- |
21
- | `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, observability, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
21
+ | `python-neva` | `neva/` | **Core, framework-agnostic.** DI, service providers, facades, Result/Option, events, security, structured logging, SQLAlchemy database layer. | published ([PyPI](https://pypi.org/project/python-neva/)) |
22
22
  | `neva-fastapi` | `neva-fastapi/` | FastAPI integration — `App` extends `FastAPI` and wires dishka into routes. Pulled in via the `python-neva[fastapi]` extra. | published ([PyPI](https://pypi.org/project/neva-fastapi/)) |
23
23
  | `neva-asgi` | `neva-asgi/` | **ASGI middleware** — correlation IDs and per-request profiling. Pure ASGI, so both the HTTP and messaging integrations can consume it. Pulled in via the `python-neva[asgi]` extra. | published ([PyPI](https://pypi.org/project/neva-asgi/)) |
24
24
  | `neva-faststream` | `neva-faststream/` | FastStream (messaging) integration. | scaffolded, early placeholder |
25
25
  | `neva-auth` | `neva-auth/` | Authentication & identity toolkit — token-issuance engine, OAuth2 grants, guards, pluggable formats/backends. | core complete, OAuth-server slice in progress |
26
+ | `neva-otel` | `neva-otel/` | **OpenTelemetry** — SDK wiring, tracer and meter providers, exporters, samplers and instrumentors. Kept out of the core so nothing is committed to an observability backend. | planned |
27
+ | `neva-boost` | `neva-boost/` | **Agent guidelines** — composes each installed package's versioned guideline fragments into a project's agent configuration (Claude Code skills or `AGENTS.md`). Dev-time tooling; pulled in via the `python-neva[boost]` extra. | published ([PyPI](https://pypi.org/project/neva-boost/)) |
26
28
  | `neva-example` | `neva-example/` | Reference application consuming `python-neva[fastapi]`. Not published; demonstration + integration test bed. | unpublished |
27
29
 
28
30
  > When working across packages locally, each repo can be cloned as a sibling
29
31
  > directory; integration packages depend on `python-neva` from PyPI (or an
30
32
  > editable local path during development).
31
33
 
34
+ ## Logging
35
+
36
+ Logging is a set of named channels, each with its own driver, level and output —
37
+ Laravel's `Log`, configured from one `config/obs.py` namespace.
38
+
39
+ ```python
40
+ config = {
41
+ "logging": {
42
+ "default": "stdout",
43
+ "channels": {
44
+ "stdout": {"driver": "console", "level": "DEBUG"},
45
+ "json": {"driver": "json", "stream": "stderr", "level": "INFO"},
46
+ "audit": {"driver": "file", "path": "var/audit.log"},
47
+ "both": {"driver": "stack", "channels": ["json", "audit"]},
48
+ },
49
+ },
50
+ }
51
+ ```
52
+
53
+ ```python
54
+ from neva.support.facade import Log
55
+
56
+ Log.info("order placed", order_id=42) # default channel
57
+ Log.channel("audit").warning("role granted", actor="root")
58
+ Log.bind(tenant="acme") # every channel, this context
59
+ ```
60
+
61
+ Drivers are `console`, `json`, `file`, `stack` and `null`. With no `config/obs.py`
62
+ at all, logging still works: a console channel on stdout at `DEBUG`.
63
+
64
+ Each channel carries its own structlog processor chain rather than going through
65
+ the process-global `structlog.configure()`, so one application's renderer cannot
66
+ leak into another in the same interpreter. `LogManager.processor(fn)` lets a
67
+ plugin add a processor to every chain — the seam `neva-otel` uses to put the
68
+ current span's ids on every record.
69
+
70
+ **Tracing and metrics are deliberately not here.** They live in `neva-otel`, so
71
+ the core carries no OpenTelemetry dependency and commits no application to an
72
+ observability backend.
73
+
32
74
  ## Develop
33
75
 
34
76
  ```bash
@@ -74,10 +116,9 @@ rewrite the new tag with the rendered changelog as its annotation.
74
116
  ## Feature ideas
75
117
 
76
118
  - Feature-parity FastStream integration (now scaffolded as `neva-faststream`)
77
- - Finish cleaning up the core package (moving any remaining ASGI-dependent code into the appropriate integration packages)
78
119
  - Improved router registration (auto-discovery OR provider-based? both?)
79
120
  - Improved security tooling (performance improvements, better defaults, etc.)
80
- - Better OpenTelemetry integration
121
+ - OpenTelemetry as a plugin (`neva-otel`) — the core keeps logging and takes no OTel dependency
81
122
  - Improved factory module (based on Polyfactory)
82
123
  - Queue/Jobs system
83
124
  - CLI integration
@@ -10,7 +10,6 @@ from neva.database.config import ConnectionConfig
10
10
  from neva.database.connection import ConnectionManager, TransactionContext
11
11
  from neva.database.transaction import BoundTransaction
12
12
  from neva.obs import LogManager
13
- from neva.obs.instrumentation.sqlalchemy import instrument
14
13
  from neva.support import Nothing, Option, Some, from_optional
15
14
 
16
15
 
@@ -36,7 +35,6 @@ class DatabaseManager:
36
35
  name: The connection name.
37
36
  engine: The async engine.
38
37
  """
39
- instrument(engine)
40
38
  self._connections[name] = ConnectionManager(
41
39
  name,
42
40
  self._tx_context,
@@ -0,0 +1,6 @@
1
+ """Guideline fragments and the manifest `neva-boost` reads to find them."""
2
+
3
+ from neva.guidelines.manifest import MANIFEST
4
+
5
+
6
+ __all__ = ["MANIFEST"]
@@ -0,0 +1,108 @@
1
+ ---
2
+ id: configuration
3
+ title: Configuration
4
+ requires: python-neva>=4.0
5
+ triggers: [reading a config value, adding a config file, Config.get, config namespace, environment settings, providers list]
6
+ priority: 50
7
+ verified_by: [tests/config/test_repository.py, tests/config/test_loader.py, tests/config/test_config_path_resolution.py, tests/arch/test_config_shapes.py]
8
+ ---
9
+
10
+ # Configuration
11
+
12
+ A flat directory of `.py` files, each exporting a module-level `config = {...}` dict. **The
13
+ filename is the namespace.** Values are read by dot-notation and come back as `Result`.
14
+
15
+ ```
16
+ src/config/app.py → "app"
17
+ src/config/database.py → "database"
18
+ src/config/providers.py → "providers"
19
+ ```
20
+
21
+ Config files are real modules executed at boot, so they may compute values and import helpers.
22
+ Keep them pure — no I/O, no connections.
23
+
24
+ ## Where the directory comes from
25
+
26
+ `config_path` argument, else `$NEVA_CONFIG_PATH`, else `./config` relative to the working
27
+ directory. Deliberately a dedicated subdirectory, never the project root: every `.py` in it is
28
+ imported to read its `config` dict, so a folder of ordinary modules would be executed.
29
+
30
+ The loader globs `*.py` except `__init__.py`, imports each file, and takes its `config`
31
+ attribute **only if it is a dict**. A file with no `config`, or a non-dict `config`, is skipped
32
+ silently — check that first when a namespace seems missing. A missing directory, a
33
+ `SyntaxError`, or any import-time exception aborts boot with `RuntimeError`.
34
+
35
+ There is no CLI, no generator, and no `ConfigServiceProvider`: loading is wired directly into
36
+ `Application.__init__`.
37
+
38
+ ## Namespaces that drive boot
39
+
40
+ | Key | Meaning |
41
+ | --- | ------- |
42
+ | `providers.providers` | Providers to register. |
43
+ | `app.providers` | Further providers, appended after those. |
44
+
45
+ ## Typed shapes
46
+
47
+ Each package declares a `TypedDict` for the keys **it** reads, so a shape never advertises keys
48
+ its own package ignores. Annotate your file to have a type checker catch typos before boot.
49
+
50
+ ```python
51
+ from neva.arch import AppConfig
52
+ config: AppConfig = {"key": "...", "providers": [ActorServiceProvider]}
53
+ ```
54
+
55
+ `AppConfig` (`neva.arch`) covers `app.key`, `app.previous_keys`, `app.providers`, all optional.
56
+ `ProviderConfig` (`neva.arch`) covers `providers.providers`. `DatabaseConfig`
57
+ (`neva.database`) covers the `database` namespace, and its `connections` key is **required**.
58
+ The `app` namespace is shared — an integration package declares the keys it adds.
59
+
60
+ ## Reading
61
+
62
+ Inject `ConfigRepository` inside services you unit-test; use the `Config` facade for
63
+ cross-cutting reads. Both resolve the same instance.
64
+
65
+ ```python
66
+ Config.get("app.title").unwrap_or("Untitled")
67
+ Config.get("app.debug", type_=bool).unwrap_or(False)
68
+ ```
69
+
70
+ `get(key, type_=None) -> Result[T, str]`. It never raises and has **no default parameter** — the
71
+ `Result` is the default mechanism.
72
+
73
+ **`type_` is a cast, not validation.** Nothing is coerced or checked:
74
+ `Config.get("app.debug", type_=bool)` over `debug = 0` returns the integer `0`, merely *typed*
75
+ as `bool`. Parse or validate separately, and wrap consumed config in a typed object.
76
+
77
+ `has(key) -> bool` is `get(key).is_ok`. `all() -> dict` returns a deep copy; mutating it never
78
+ reaches the repository, frozen or not.
79
+
80
+ ## Writing
81
+
82
+ Mutable until frozen. This is the extension seam — a package registers defaults, an application
83
+ overrides leaves — and the seam tests use.
84
+
85
+ `set(key, value)` sets one dot-notation path, creating intermediate dicts, and errors if an
86
+ intermediate segment holds a non-dict.
87
+
88
+ **`merge(key, items)` is a recursive deep merge.** Nested dictionaries are combined rather than
89
+ replaced, so a leaf can be overridden without restating its siblings; only non-dict values are
90
+ replaced outright. Overriding one nested key needs nothing special:
91
+
92
+ ```python
93
+ Config.merge("hashing", {"argon": {"time_cost": 2, "memory_cost": 1024}})
94
+ Config.merge("hashing", {"argon": {"time_cost": 4}})
95
+ # → argon == {"time_cost": 4, "memory_cost": 1024}
96
+ ```
97
+
98
+ Boot uses exactly this: each loaded `{namespace: dict}` is merged in.
99
+
100
+ `freeze()` makes every later `set`/`merge` return `Err`. **The framework never calls it.** Config
101
+ stays writable at runtime, which is what keeps the extension seam and test overrides working.
102
+ Call it yourself if you want hard immutability after startup.
103
+
104
+ ## In tests
105
+
106
+ `TestCase.create_config(tmp_path)` returns the directory the application is built from. Override
107
+ it as a **classmethod** — see the testing fragment. Because the repository is unfrozen you can
108
+ also adjust values mid-test with `Config.set` / `Config.merge`, or fake the facade.
@@ -0,0 +1,125 @@
1
+ ---
2
+ id: database-transactions
3
+ title: Database and transactions
4
+ requires: python-neva>=4.0
5
+ triggers: [database query, opening a transaction, DB.begin, session, savepoint, on_commit, rollback, SQLAlchemy]
6
+ priority: 40
7
+ verified_by: [tests/database/test_transaction.py, tests/database/test_transaction_callbacks.py, tests/database/test_transaction_context.py, tests/database/test_transaction_registry.py, tests/database/test_connection_manager.py, tests/database/test_database_manager.py, tests/database/test_multi_connection.py, tests/database/test_sqlalchemy_integration.py]
8
+ ---
9
+
10
+ # Database and transactions
11
+
12
+ A transaction-first wrapper over SQLAlchemy async. Never build an engine or session, never call
13
+ `session.commit()`. Open a unit of work, work through `tx.session`, and the block commits on
14
+ clean exit or rolls back on error.
15
+
16
+ ```python
17
+ async with DB.begin() as tx:
18
+ tx.session.add(actor)
19
+ await tx.session.flush()
20
+ ```
21
+
22
+ ## Configuring
23
+
24
+ One `database` namespace, mapping connection name to `ConnectionConfig`. `url` is required and
25
+ must use an **async** driver; `pool_size`, `max_overflow`, `pool_recycle`, `pool_pre_ping` and
26
+ `echo` are optional and pass straight to `create_async_engine`.
27
+
28
+ ```python
29
+ config: DatabaseConfig = {
30
+ "connections": {"default": {"url": "postgresql+asyncpg://…", "echo": True}}
31
+ }
32
+ ```
33
+
34
+ `DatabaseServiceProvider` binds `TransactionContext` and `DatabaseManager`, then registers each
35
+ configured connection during `lifespan()` and disposes the engines on shutdown. Session
36
+ factories use `expire_on_commit=False`, so ORM objects stay readable after commit.
37
+
38
+ **A missing or malformed `database` namespace does not fail boot.** The provider logs the error
39
+ and continues with zero connections; the failure then surfaces much later as
40
+ `RuntimeError: No connection registered with name default` from the first `DB.begin()`. If you
41
+ see that error, check the config namespace first.
42
+
43
+ ## DatabaseManager
44
+
45
+ | Call | Returns | Purpose |
46
+ | ---- | ------- | ------- |
47
+ | `begin(name="default")` | ctx mgr of `BoundTransaction` | Open a unit of work. `RuntimeError` if the name is unregistered. |
48
+ | `session(connection=None)` | `Option[AsyncSession]` | Current session, only if this task may use it. |
49
+ | `current(connection=None)` | `Option[BoundTransaction]` | Innermost transaction (top of stack), or the one for a named connection. |
50
+ | `outermost()` | `Option[BoundTransaction]` | The transaction that commits **last**. What deferred events attach to. |
51
+ | `connection(name)` | `Option[ConnectionManager]` | The connection manager. |
52
+ | `register_connection(name, cfg)` | `None` | Build an engine from config. The normal path. |
53
+ | `register_engine(name, engine)` | `None` | Adopt an engine the caller built. For a schema on an in-memory database, where a second engine would be a second, empty database. |
54
+ | `close()` | `None` | Dispose every engine. |
55
+
56
+ Inject `DatabaseManager` in services you unit-test; use the `DB` facade for ambient access.
57
+
58
+ ## The caller owns the transaction
59
+
60
+ An inner service reads the current session rather than opening its own:
61
+
62
+ ```python
63
+ class CreateActor:
64
+ def __init__(self, db: DatabaseManager) -> None:
65
+ self._db = db
66
+
67
+ async def __call__(self, cmd: CreateActorCommand) -> Actor:
68
+ session = self._db.session().expect("CreateActor must run inside DB.begin()")
69
+ ...
70
+ ```
71
+
72
+ `session()` returns `Nothing` when no transaction is open **or** when the open one belongs to a
73
+ different asyncio task. Handle the `Option`; it is usually a wiring mistake.
74
+
75
+ ## BoundTransaction
76
+
77
+ `session` (non-null inside the block), `on_commit(cb)`, `on_rollback(cb)`, `rollback()`,
78
+ `share()`, `is_root`, `is_active`, `parent`.
79
+
80
+ `await tx.rollback()` **marks** the transaction; the block then exits normally and nothing is
81
+ raised. Only an exception propagating out of the block rolls back *and* re-raises.
82
+
83
+ ## Nesting is a savepoint
84
+
85
+ A second `begin()` on the same connection, from a task that can reach the parent, reuses the
86
+ parent session and opens a `SAVEPOINT`. An inner rollback undoes only the savepoint; the outer
87
+ transaction continues. Independent code paths can each wrap their work without knowing whether
88
+ they are outermost.
89
+
90
+ ## Commit and rollback callbacks
91
+
92
+ ```python
93
+ tx.on_commit(lambda: send_confirmation_email(order.id))
94
+ tx.on_rollback(release_reserved_stock)
95
+ ```
96
+
97
+ - **Sync or async, and `None` is a valid return.** The contract is
98
+ `Callable[[], Result[None, str] | None | Awaitable[...]]`. A bare side-effecting lambda is
99
+ fine; returning `None` is read as `Ok(None)`.
100
+ - **They bubble to the root** and fire only when the root settles — commit callbacks on commit,
101
+ rollback callbacks on rollback.
102
+ - **A raising callback is captured as `Err` and logged.** It never breaks the settle path or the
103
+ other callbacks, and it cannot abort a commit that already happened.
104
+ - **They run with no ambient transaction.** By the time one runs, the session is committed and
105
+ the transaction is off the registry, so `DB.current()` and `DB.session()` return `Nothing`.
106
+ This is what makes a callback the right place for an SMTP send or an HTTP call — none of it
107
+ pins the pooled connection or holds a lock. A callback needing the database opens its own
108
+ `DB.begin()`.
109
+
110
+ This is the machinery behind `DEFERRED` events.
111
+
112
+ ## Task-bound sessions
113
+
114
+ A `BoundTransaction` records the asyncio task that opened it, and `AsyncSession` is not
115
+ concurrent-safe, so another task gets `Nothing` from `session()` and opens a fresh transaction
116
+ instead of reusing it. `tx.share()` lifts that for controlled **sequential** access only — its
117
+ canonical use is `RefreshDatabase`.
118
+
119
+ ## Pitfalls
120
+
121
+ - Never call `session.commit()` / `session.rollback()`. The block owns settling.
122
+ - Never construct `AsyncEngine` / `AsyncSession` by hand.
123
+ - A nested rollback undoes the savepoint, not the transaction.
124
+ - `session()` and `current()` return `Option`. Handle `Nothing`.
125
+ - Use an async driver end to end.
@@ -0,0 +1,150 @@
1
+ ---
2
+ id: events
3
+ title: Events and handling policies
4
+ requires: python-neva>=4.0
5
+ triggers: [dispatching an event, writing a listener, DEFERRED vs IMMEDIATE, reacting to something that happened, side effect after commit]
6
+ priority: 30
7
+ verified_by: [tests/events/test_deferred.py, tests/events/test_immediate.py, tests/events/test_dispatch.py, tests/events/test_listen_on_parent_class.py, tests/events/test_function_listener.py, tests/events/test_before_dispatch.py, tests/events/test_listener_wiring.py]
8
+ ---
9
+
10
+ # Events and handling policies
11
+
12
+ A producer announces what happened; unrelated listeners react. The defining feature is
13
+ transaction awareness: a `DEFERRED` listener runs only after the surrounding database
14
+ transaction commits.
15
+
16
+ ```python
17
+ from neva.support.facade import Event
18
+ _ = await Event.dispatch(ActorCreated(actor_id=actor.id))
19
+ ```
20
+
21
+ ## Defining an event
22
+
23
+ Subclass `Event[T]`, where `T` types the `event_id`. The base supplies `event_id: T` and
24
+ `timestamp: datetime` (defaulted to `neva.support.time.utcnow`). Events are Pydantic models, so
25
+ they validate on construction and serialise.
26
+
27
+ ```python
28
+ class ActorCreated(Event[uuid.UUID]):
29
+ actor_id: uuid.UUID
30
+ ```
31
+
32
+ Most applications define one project base event that fixes the id strategy
33
+ (`event_id: uuid.UUID = pydantic.Field(default_factory=uuid.uuid4)`) and let component events
34
+ declare payload only.
35
+
36
+ Naming: events are **past tense**.
37
+
38
+ ## Writing a listener
39
+
40
+ ```python
41
+ class SendWelcomeEmail(EventListener[ActorCreated]):
42
+ policy = HandlingPolicy.DEFERRED # default is IMMEDIATE
43
+
44
+ def __init__(self, mailer: Mailer) -> None:
45
+ self._mailer = mailer
46
+
47
+ async def handle(self, event: ActorCreated) -> Result[None, str]:
48
+ await self._mailer.send_welcome(event.actor_id)
49
+ return Ok(None)
50
+ ```
51
+
52
+ Or, with no dependencies, decorate a handler — `@listener` synthesises a class named
53
+ `<Pascal>Listener` (`log_actor_created` → `LogActorCreatedListener`):
54
+
55
+ ```python
56
+ @listener(policy=HandlingPolicy.DEFERRED) # bare @listener() is IMMEDIATE
57
+ async def log_actor_created(event: ActorCreated) -> Result[None, str]:
58
+ Log.info("actor created", actor_id=str(event.actor_id))
59
+ return Ok(None)
60
+ ```
61
+
62
+ Naming: class listeners are imperative (`SendWelcomeEmail`), function handlers snake_case.
63
+
64
+ **Always define `handle`.** The base class body is `...` and is not abstract, so an omitted
65
+ `handle` silently returns `None` where the contract promises `Result[None, str]`.
66
+
67
+ **Return `Err`, never raise.** A raised exception is captured as `Err(f"Listener raised: {e}")`
68
+ and never reaches the producer or the other listeners.
69
+
70
+ ## Policy × transaction
71
+
72
+ | Policy | Inside an open transaction | Outside any transaction |
73
+ | ------ | -------------------------- | ----------------------- |
74
+ | `IMMEDIATE` | runs during `dispatch()` | runs during `dispatch()` |
75
+ | `DEFERRED` | runs after the transaction commits | runs during `dispatch()` |
76
+
77
+ `DEFERRED` is for effects that must not happen unless the data persisted — mail, indexing,
78
+ webhooks, outbound calls. On rollback they never fire. With no transaction open, `DEFERRED`
79
+ degrades to immediate: same call site, different timing by context.
80
+
81
+ ## What dispatch does
82
+
83
+ 1. Runs `before_dispatch` hooks.
84
+ 2. Resolves listeners, walking the event's **MRO** — a listener registered against a base event
85
+ also fires for subclasses. A listener reached through several registries fires **once**;
86
+ repeated registrations against the *same* event are honoured and fire repeatedly.
87
+ 3. Splits by policy.
88
+ 4. Branches on `DatabaseManager.outermost()` — the transaction that commits **last**, not
89
+ `current()`. Deferred listeners attach to it via `on_commit`, so they never run for work an
90
+ enclosing transaction may still roll back. With no transaction, deferred listeners join the
91
+ immediate list.
92
+ 5. Runs immediate listeners, resolving each from the container and awaiting `handle`.
93
+ 6. Returns one `Result` per **immediate** listener.
94
+
95
+ The returned list therefore says nothing about deferred work. Never use it to confirm a
96
+ deferred listener ran.
97
+
98
+ ## Resolution
99
+
100
+ `_resolve_listener` asks the container, then falls back to no-argument instantiation. A listener
101
+ with constructor dependencies **must** be bound — the `listen` ClassVar does that for you.
102
+
103
+ ## Registering
104
+
105
+ Declarative, and preferred:
106
+
107
+ ```python
108
+ class ActorServiceProvider(ServiceProvider):
109
+ listen = {ActorCreated: [SendWelcomeEmail, IndexActorForSearch]}
110
+ ```
111
+
112
+ Listener classes are bound at `Scope.APP` onto the application's root provider during
113
+ registration, then wired onto the dispatcher during `lifespan()`. If `listen` mappings exist and
114
+ the dispatcher cannot be resolved, boot **raises `RuntimeError`** naming the count and the fix
115
+ rather than leaving every listener silently unwired.
116
+
117
+ Imperative registration also works. Note the type stubs currently declare only the single-class
118
+ form, so a list argument type-checks as an error even though the dispatcher accepts it:
119
+
120
+ ```python
121
+ Event.listen(ActorCreated, SendWelcomeEmail)
122
+ ```
123
+
124
+ `EventServiceProvider` is a base provider — always registered, never added by hand.
125
+
126
+ ## before_dispatch hooks
127
+
128
+ Run once per dispatch, before any listener. The **hook** may be sync or async; the
129
+ **registration call is always awaited**, because `before_dispatch` is itself a coroutine
130
+ function. Forgetting the `await` registers nothing.
131
+
132
+ ```python
133
+ await Event.before_dispatch(persist_to_event_store)
134
+ await Event.before_dispatch(lambda e: metrics.incr(type(e).__name__))
135
+ ```
136
+
137
+ ## Testing
138
+
139
+ `Event.fake()` installs an `EventFake` that records events and fires **no** listeners — neither
140
+ immediate nor deferred — and whose `dispatch` always returns `[]`.
141
+
142
+ ```python
143
+ fake = Event.fake()
144
+ fake.assert_dispatched(ActorCreated, times=1, match=lambda e: e.actor_id == expected)
145
+ fake.assert_not_dispatched(ActorDeleted)
146
+ events = fake.dispatched(ActorCreated)
147
+ ```
148
+
149
+ `with Event.faking() as fake:` scopes it. `TestCase` restores facades after every test. To test
150
+ what a listener *does*, exercise the listener directly — the fake suppresses it.