openframe-core 3.2.1__tar.gz → 3.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 (152) hide show
  1. {openframe_core-3.2.1 → openframe_core-3.4.0}/.github/CHANGELOG.md +143 -8
  2. {openframe_core-3.2.1 → openframe_core-3.4.0}/.gitignore +1 -0
  3. {openframe_core-3.2.1 → openframe_core-3.4.0}/PKG-INFO +2 -2
  4. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/health.md +4 -3
  5. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/plugins.md +27 -13
  6. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/ports.md +29 -17
  7. openframe_core-3.4.0/docs/code/modules/runtime.md +170 -0
  8. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/developer-guide/debugging.md +12 -9
  9. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/developer-guide/explore.md +2 -0
  10. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/developer-guide/how-it-works.md +45 -1
  11. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/developer-guide/quick-start/index.md +21 -10
  12. openframe_core-3.4.0/docs/technical/architecture/adrs/adr-007-schema-contracts.md +111 -0
  13. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/adrs/index.md +2 -0
  14. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/design-decisions.md +37 -35
  15. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/adapter-lifecycle.md +2 -2
  16. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/runbooks/adapter-connection-failure.md +4 -4
  17. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/runbooks/postgres-pool-exhausted.md +1 -1
  18. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/runbooks/queue-consumer-stuck.md +2 -2
  19. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/runbooks/redis-connection-lost.md +1 -1
  20. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/overview/glossary.md +2 -2
  21. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/overview/roadmap.md +39 -8
  22. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/exceptions/plugin.py +1 -0
  23. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/plugins/__init__.py +3 -3
  24. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/plugins/registry.py +60 -11
  25. openframe_core-3.4.0/openframe/core/resilience/__init__.py +44 -0
  26. openframe_core-3.4.0/openframe/core/resilience/circuit_breaker.py +260 -0
  27. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/runtime/__init__.py +4 -1
  28. openframe_core-3.4.0/openframe/core/runtime/bootstrap.py +299 -0
  29. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/tracing/proxy.py +2 -2
  30. {openframe_core-3.2.1 → openframe_core-3.4.0}/pyproject.toml +1 -1
  31. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_exceptions.py +5 -0
  32. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_plugins.py +89 -0
  33. openframe_core-3.4.0/tests/test_resilience.py +233 -0
  34. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_runtime.py +131 -0
  35. openframe_core-3.2.1/docs/code/modules/runtime.md +0 -122
  36. openframe_core-3.2.1/openframe/core/runtime/bootstrap.py +0 -196
  37. {openframe_core-3.2.1 → openframe_core-3.4.0}/.github/requirements/docs.txt +0 -0
  38. {openframe_core-3.2.1 → openframe_core-3.4.0}/.github/requirements/test.txt +0 -0
  39. {openframe_core-3.2.1 → openframe_core-3.4.0}/.github/workflows/app-test.yml +0 -0
  40. {openframe_core-3.2.1 → openframe_core-3.4.0}/.github/workflows/auto-docs.yml +0 -0
  41. {openframe_core-3.2.1 → openframe_core-3.4.0}/.github/workflows/python-build.yml +0 -0
  42. {openframe_core-3.2.1 → openframe_core-3.4.0}/.vscode/settings.json +0 -0
  43. {openframe_core-3.2.1 → openframe_core-3.4.0}/LICENSE +0 -0
  44. {openframe_core-3.2.1 → openframe_core-3.4.0}/README.md +0 -0
  45. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/assets/images/banner.png +0 -0
  46. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/assets/images/favicon.png +0 -0
  47. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/assets/images/logo-dark.png +0 -0
  48. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/assets/images/logo-light.png +0 -0
  49. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/assets/javascripts/extra.js +0 -0
  50. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/assets/stylesheets/extra.css +0 -0
  51. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/cicd/pipeline.md +0 -0
  52. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/getting-started/index.md +0 -0
  53. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/config.md +0 -0
  54. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/exceptions.md +0 -0
  55. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/inbound.md +0 -0
  56. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/index.md +0 -0
  57. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/middleware.md +0 -0
  58. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/telemetry.md +0 -0
  59. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/testing.md +0 -0
  60. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/code/modules/tracing.md +0 -0
  61. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/developer-guide/first-change.md +0 -0
  62. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/developer-guide/local-dev.md +0 -0
  63. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/index.md +0 -0
  64. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-001-namespace.md +0 -0
  65. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-002-hexagonal.md +0 -0
  66. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-003-async.md +0 -0
  67. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-004-pydantic.md +0 -0
  68. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-005-middleware.md +0 -0
  69. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/adrs/adr-006-unified-port-lifecycle.md +0 -0
  70. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/capability-taxonomy.md +0 -0
  71. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/component-lifecycle.md +0 -0
  72. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/data-flows.md +0 -0
  73. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/error-taxonomy.md +0 -0
  74. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/error-propagation.md +0 -0
  75. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/index.md +0 -0
  76. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/template-wiring.md +0 -0
  77. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/package-journey/tracing-flow.md +0 -0
  78. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/architecture/system-architecture.md +0 -0
  79. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/deployment/cicd.md +0 -0
  80. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/deployment/environments.md +0 -0
  81. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/deployment/secrets-and-config.md +0 -0
  82. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/infrastructure/networking.md +0 -0
  83. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/infrastructure/platform.md +0 -0
  84. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/infrastructure/storage.md +0 -0
  85. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/index.md +0 -0
  86. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/monitoring.md +0 -0
  87. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/runbooks/modal-cold-start-spike.md +0 -0
  88. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/operations/runbooks/telemetry-export-failing.md +0 -0
  89. {openframe_core-3.2.1 → openframe_core-3.4.0}/docs/technical/overview/system-overview.md +0 -0
  90. {openframe_core-3.2.1 → openframe_core-3.4.0}/mkdocs.yml +0 -0
  91. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/__init__.py +0 -0
  92. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/__init__.py +0 -0
  93. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/config/__init__.py +0 -0
  94. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/config/settings.py +0 -0
  95. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/exceptions/__init__.py +0 -0
  96. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/exceptions/adapter.py +0 -0
  97. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/exceptions/base.py +0 -0
  98. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/exceptions/codes.py +0 -0
  99. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/inbound/__init__.py +0 -0
  100. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/inbound/command.py +0 -0
  101. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/inbound/context.py +0 -0
  102. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/inbound/query.py +0 -0
  103. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/inbound/usecase.py +0 -0
  104. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/middleware/__init__.py +0 -0
  105. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/middleware/telemetry.py +0 -0
  106. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/middleware/types.py +0 -0
  107. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/__init__.py +0 -0
  108. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/capability.py +0 -0
  109. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/context.py +0 -0
  110. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/health.py +0 -0
  111. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/identity.py +0 -0
  112. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/lifecycle.py +0 -0
  113. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/outbound/__init__.py +0 -0
  114. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/outbound/consumer.py +0 -0
  115. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/outbound/producer.py +0 -0
  116. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/outbound/repository.py +0 -0
  117. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/ports/port.py +0 -0
  118. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/schemas/__init__.py +0 -0
  119. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/telemetry/__init__.py +0 -0
  120. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/telemetry/setup.py +0 -0
  121. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/__init__.py +0 -0
  122. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/contracts/__init__.py +0 -0
  123. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/contracts/consumer.py +0 -0
  124. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/contracts/inbound.py +0 -0
  125. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/contracts/lifecycle.py +0 -0
  126. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/contracts/port.py +0 -0
  127. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/contracts/producer.py +0 -0
  128. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/contracts/repository.py +0 -0
  129. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/fakes/__init__.py +0 -0
  130. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/fakes/consumer.py +0 -0
  131. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/fakes/inbound.py +0 -0
  132. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/fakes/producer.py +0 -0
  133. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/fakes/repository.py +0 -0
  134. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/testing/fixtures.py +0 -0
  135. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/tracing/__init__.py +0 -0
  136. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/core/tracing/propagation.py +0 -0
  137. {openframe_core-3.2.1 → openframe_core-3.4.0}/openframe/py.typed +0 -0
  138. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/conftest.py +0 -0
  139. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_config.py +0 -0
  140. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_inbound.py +0 -0
  141. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_inbound_contracts.py +0 -0
  142. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_middleware.py +0 -0
  143. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_middleware_types.py +0 -0
  144. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_ports.py +0 -0
  145. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_ports_primitives.py +0 -0
  146. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_record_error.py +0 -0
  147. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_schemas.py +0 -0
  148. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_shutdown_telemetry.py +0 -0
  149. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_telemetry_sidecar.py +0 -0
  150. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_testing_fakes.py +0 -0
  151. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_tracing.py +0 -0
  152. {openframe_core-3.2.1 → openframe_core-3.4.0}/tests/test_tracing_propagation.py +0 -0
@@ -9,17 +9,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
- ### Watch items (architectural)
12
+ ---
13
13
 
14
- - **Three application wiring options** — `deps.py` + `lru_cache`,
15
- `PluginRegistry` direct, and `ApplicationBootstrap` now coexist without a
16
- documented hierarchy. The ambiguity is better-named than before but still
17
- unresolved. The next documentation pass must establish `ApplicationBootstrap`
18
- as the default starting point and frame the other two as explicit alternatives
19
- with stated conditions. Tracked in the roadmap.
14
+ ## v3.4.0 — 2026-09-28
20
15
 
21
16
  ### Added
22
17
 
18
+ - **`openframe.core.resilience.CircuitBreakerProxy`** (new module,
19
+ experimental) — zero-code async circuit breaker. Wraps any object and
20
+ short-circuits its async methods after `failure_threshold` consecutive
21
+ failures, raising `AdapterConnectionError` ("circuit open", retryable)
22
+ immediately instead of letting every caller block until
23
+ `operation_timeout` on every single call during a sustained outage.
24
+ Standard closed → open → half-open → closed/open state machine.
25
+ Mirrors `TracingProxy`'s exact wrapping mechanism (`__getattr__`
26
+ interception, fresh `getattr` resolution of the wrapped method on every
27
+ call, sync methods pass through unwrapped) so the two proxies compose
28
+ predictably — see the module docstring for the recommended composition
29
+ order (`CircuitBreakerProxy(TracingProxy(repo, ...), ...)`, not the
30
+ reverse, so a short-circuited call never produces a misleading adapter
31
+ span). Addresses a real gap: none of the four `openframe-adapters`
32
+ packages (or anything else in the ecosystem) previously had any
33
+ resilience pattern beyond "block until timeout, then raise."
34
+
35
+ ### Changed
36
+
37
+ - **Graduated `PluginRegistry` and `ApplicationBootstrap` from `experimental`
38
+ to `stable`** (`openframe.core.plugins.registry`, `openframe.core.runtime.bootstrap`,
39
+ and their respective package `__init__.py` re-exports). Graduation
40
+ criteria: (1) all 7 `openframe-local-validation-framework` services
41
+ already depend on both in production-shaped code across multiple
42
+ releases with no breaking change required of them; (2) v3.3.0's
43
+ `ApplicationBootstrap.compose()`/`get_all()`/`registry` additions landed
44
+ as pure additive extensions on top of the existing contract — no
45
+ breaking change was needed to add meaningful new functionality, which is
46
+ itself evidence the contract shape is right. This is the first module in
47
+ `openframe-core` to graduate via an explicit, written criteria set
48
+ (documented in each module's own docstring) rather than an unstated
49
+ judgment call — future modules going through the same
50
+ experimental→stable path should record their own criteria the same way.
51
+
52
+ ---
53
+
54
+ ## v3.3.0 — 2026-09-28
55
+
56
+ ### Added
57
+
58
+ - **`ApplicationBootstrap.compose(*ports)`, `.get_all()`, `.registry`** —
59
+ resolves the long-standing "three wiring options" ambiguity (previously
60
+ tracked here as an architectural watch item and in the roadmap) by
61
+ consolidating to one recommended class at three levels of ceremony
62
+ instead of three competing patterns:
63
+ - `compose(*ports)` (new classmethod) — zero-subclass entry point for a
64
+ service with one or a few ports and no per-port `config`/`init_timeout`.
65
+ Registers each port immediately at construction; `configure()` stays
66
+ the default no-op.
67
+ - `get_all(capability)` (new method) — mirrors `PluginRegistry.get_all()`,
68
+ closing the one real gap that previously forced a multi-port-per-capability
69
+ setup (e.g. primary + replica) off `ApplicationBootstrap` and onto
70
+ `PluginRegistry` directly.
71
+ - `registry` (new read-only property) — exposes the underlying
72
+ `PluginRegistry` for customization neither of the above covers (e.g.
73
+ `list_plugins()`), without standing up a second, parallel registry.
74
+
75
+ `PluginRegistry` direct construction and `deps.py` + `lru_cache` are no
76
+ longer documented as peer alternatives to `ApplicationBootstrap` — see
77
+ `docs/technical/architecture/design-decisions.md` and
78
+ `docs/developer-guide/how-it-works.md`'s new "Choosing a Wiring Pattern"
79
+ section for the full resolution.
80
+
81
+ - **`PluginRegistry`/`ApplicationBootstrap` per-port init timeout** —
82
+ `PluginRegistry(default_init_timeout=...)` and
83
+ `PluginRegistry.register(..., init_timeout=...)` (mirrored on
84
+ `ApplicationBootstrap`'s constructor and `register()`) bound how long
85
+ `initialize_all()` waits for a single port's `initialize()` before
86
+ giving up. Both default to `None` (no timeout), preserving prior
87
+ behaviour exactly for callers who don't opt in. Addresses a real
88
+ production risk: previously, one adapter's `initialize()` hanging
89
+ (e.g. a TCP connection that completes the handshake but never
90
+ responds) would block application startup indefinitely with no way
91
+ to bound it. A timeout surfaces as a plain `TimeoutError` and is
92
+ handled identically to any other `initialize()` failure — already-
93
+ initialized ports are rolled back (LIFO) before it propagates.
94
+
23
95
  - **`openframe.core.telemetry.shutdown_telemetry()`** — flushes and shuts
24
96
  down the OTel SDK, ensuring the `BatchSpanProcessor` exports all buffered
25
97
  spans before process exit. Idempotent, never raises, resets `_INITIALISED`
@@ -37,6 +109,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
37
109
 
38
110
  ### Fixed
39
111
 
112
+ #### Code
113
+
114
+ - `TracingProxy` (`tracing/proxy.py`) — replaced the deprecated
115
+ `asyncio.iscoroutinefunction()` sync/async probe with
116
+ `inspect.iscoroutinefunction()`. `asyncio.iscoroutinefunction` is
117
+ slated for removal in Python 3.16; this was the only deprecation
118
+ warning in the test suite.
119
+ - `AmbiguousCapabilityError` (`exceptions/plugin.py`) — its `capability`
120
+ and `matches` constructor arguments are now also mirrored into
121
+ `self.context`, matching the pattern every other `AdapterError`/
122
+ `PluginError` subclass already follows (e.g. `AdapterError` mirrors
123
+ `adapter`/`operation` into `context`). Previously these two fields
124
+ were set as plain attributes but silently absent from `context`.
125
+
126
+ #### Documentation
127
+
128
+ - Corrected `PluginStatus` across `docs/technical/overview/glossary.md`,
129
+ `docs/code/modules/ports.md`, `docs/code/modules/health.md`, and four
130
+ operational runbooks — several described a `READY`/`DEGRADED`/
131
+ `UNAVAILABLE` enum that has never existed in source. The actual enum
132
+ (`ports/health.py`) is `DISCOVERED`/`REGISTERED`/`CONFIGURED`/
133
+ `INITIALIZED`/`READY`/`STOPPING`/`STOPPED`/`FAILED`; a failed health
134
+ check reports `FAILED`, with finer detail carried in
135
+ `PluginHealth.message`/`.details`, not a dedicated status value.
136
+ - Corrected the `PluginContext`, `PrincipalContext`, and `TenantContext`
137
+ examples in `docs/code/modules/ports.md` — the shown `PluginContext`
138
+ was missing the required `plugin_name` field entirely and typed
139
+ `config` as `dict` instead of `Mapping`; `PrincipalContext`/
140
+ `TenantContext` showed fields (`attributes`, `roles: frozenset[str]`)
141
+ that don't exist on the real dataclasses (`claims`,
142
+ `roles: tuple[str, ...]`, `name: str`).
143
+ - Added [ADR-007](../docs/technical/architecture/adrs/adr-007-schema-contracts.md)
144
+ documenting the `openframe.core.schemas` `@contract` marker shipped in
145
+ v3.2.1 — the module's own docstring already cited "ADR-007" before
146
+ this record existed.
147
+ - Corrected `docs/code/modules/plugins.md` and `docs/code/modules/runtime.md`,
148
+ both stale independently of the init-timeout addition above:
149
+ `initialize_all()` was documented as taking `principal`/`tenant`
150
+ parameters directly (they're actually set separately via
151
+ `PluginRegistry.set_context()`, undocumented until now); `ApplicationBootstrap`
152
+ was documented as having a `get_all()` method that did not exist on
153
+ that class at the time (only on `PluginRegistry` itself) — since
154
+ resolved by actually adding `get_all()` to `ApplicationBootstrap`, see
155
+ the wiring-consolidation entry above; `ApplicationBootstrap.health()`
156
+ — a real method — was undocumented entirely; and `start()`/`initialize_all()`
157
+ were both documented as raising a wrapped `PluginInitializationError`,
158
+ when in fact the registry re-raises whatever `initialize()` raised
159
+ (or a plain `TimeoutError`) completely unwrapped.
160
+ - Corrected `docs/developer-guide/debugging.md`'s "PluginRegistry Failures"
161
+ section, which repeated the same `PluginInitializationError` inaccuracy
162
+ above, and additionally claimed `get()` raises `PluginNotFoundError` for
163
+ a missing capability — it actually raises a plain `KeyError`.
164
+ `PluginNotFoundError` and `PluginInitializationError` both exist in the
165
+ public exception hierarchy (`openframe.core.exceptions`) but neither is
166
+ currently raised anywhere in `openframe-core`'s own source — flagged as
167
+ a roadmap item (dead-code exception classes), not fixed here, since
168
+ making them actually get raised would be a behavioral change.
169
+
40
170
  #### CI/CD
41
171
 
42
172
  - Upgraded all GitHub Actions to Node.js 24-compatible versions, resolving
@@ -295,6 +425,11 @@ Dev dependencies: `pytest>=8.0`, `pytest-asyncio>=0.23`, `httpx>=0.27`.
295
425
 
296
426
  ---
297
427
 
298
- [Unreleased]: https://github.com/openframe-org/openframe-core/compare/v3.0.0...HEAD
428
+ [Unreleased]: https://github.com/openframe-org/openframe-core/compare/v3.4.0...HEAD
429
+ [3.4.0]: https://github.com/openframe-org/openframe-core/compare/v3.3.0...v3.4.0
430
+ [3.3.0]: https://github.com/openframe-org/openframe-core/compare/v3.2.1...v3.3.0
431
+ [3.2.1]: https://github.com/openframe-org/openframe-core/compare/v3.2.0...v3.2.1
432
+ [3.2.0]: https://github.com/openframe-org/openframe-core/compare/v3.1.0...v3.2.0
433
+ [3.1.0]: https://github.com/openframe-org/openframe-core/compare/v3.0.0...v3.1.0
299
434
  [3.0.0]: https://github.com/openframe-org/openframe-core/compare/v1.0.0...v3.0.0
300
435
  [1.0.0]: https://github.com/openframe-org/openframe-core/releases/tag/v1.0.0
@@ -219,3 +219,4 @@ __marimo__/
219
219
 
220
220
  # Miscellaneous Files
221
221
  .DS_Store
222
+ .claude/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: openframe-core
3
- Version: 3.2.1
3
+ Version: 3.4.0
4
4
  Summary: OpenFrame Microservice Suite - platform kernel. Port contracts, testing utilities, plugin registry, and composition root.
5
5
  Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-core
6
6
  Project-URL: Documentation, https://furious-meteors.github.io/openframe-core/
@@ -11,7 +11,7 @@ async def health(self) -> PluginHealth:
11
11
  return PluginHealth(status=PluginStatus.READY)
12
12
  except Exception as exc:
13
13
  return PluginHealth(
14
- status=PluginStatus.UNAVAILABLE,
14
+ status=PluginStatus.FAILED,
15
15
  message=str(exc),
16
16
  )
17
17
  ```
@@ -28,9 +28,10 @@ PluginHealth(
28
28
  details={"ping_ms": 2.1, "schema_ok": True},
29
29
  )
30
30
 
31
- # Degraded
31
+ # Degraded but still serving — no separate status exists; encode the
32
+ # nuance in message/details while status stays READY
32
33
  PluginHealth(
33
- status=PluginStatus.DEGRADED,
34
+ status=PluginStatus.READY,
34
35
  message="replica lag 5s",
35
36
  details={"lag_s": 5},
36
37
  )
@@ -21,18 +21,25 @@ from openframe.core.ports import Capability
21
21
 
22
22
  ```python
23
23
  class PluginRegistry:
24
+ def __init__(self, *, default_init_timeout: float | None = None) -> None: ...
25
+
24
26
  def register(
25
27
  self,
26
- port: BasePort,
27
- config: dict[str, Any] | None = None,
28
+ plugin: BasePort,
29
+ *,
30
+ config: Mapping[str, Any] | None = None,
31
+ init_timeout: float | None = None,
28
32
  ) -> None: ...
29
33
 
30
- async def initialize_all(
34
+ def set_context(
31
35
  self,
36
+ *,
32
37
  principal: PrincipalContext | None = None,
33
38
  tenant: TenantContext | None = None,
34
39
  ) -> None: ...
35
40
 
41
+ async def initialize_all(self) -> None: ...
42
+
36
43
  async def shutdown_all(self) -> None: ...
37
44
 
38
45
  def get(self, capability: Capability) -> BasePort: ...
@@ -42,33 +49,40 @@ class PluginRegistry:
42
49
 
43
50
  ---
44
51
 
45
- #### `register(port, config)`
52
+ #### `register(plugin, config, init_timeout)`
46
53
 
47
- Register a `BasePort` with optional config. The config dict is passed into `PluginContext.config` when `initialize_all()` is called.
54
+ Register a `BasePort` with optional config and an optional per-port init timeout. The config mapping is passed into `PluginContext.config` when `initialize_all()` is called.
48
55
 
49
56
  **Parameters:**
50
57
 
51
58
  | Name | Type | Default | Description |
52
59
  |---|---|---|---|
53
- | `port` | `BasePort` | — | Any `BasePort` instance (including `BaseRepository`, `BaseProducer`, `BaseConsumer`) |
54
- | `config` | `dict[str, Any] \| None` | `None` | Port-specific configuration dict, e.g. `{"dsn": "postgres://..."}` |
60
+ | `plugin` | `BasePort` | — | Any `BasePort` instance (including `BaseRepository`, `BaseProducer`, `BaseConsumer`) |
61
+ | `config` | `Mapping[str, Any] \| None` | `None` | Port-specific configuration, e.g. `{"dsn": "postgres://..."}` |
62
+ | `init_timeout` | `float \| None` | `None` | Seconds to wait for this port's `initialize()` in `initialize_all()`, overriding the registry's `default_init_timeout` for this port only. `None` falls back to that default (which is itself `None` — no timeout — unless set on the constructor) |
55
63
 
56
- **Raises:** `DuplicatePluginError` — if the same port instance is registered more than once.
64
+ **Raises:** `TypeError` (object doesn't satisfy `BasePort`) · `DuplicatePluginError` — if a port with this name is already registered.
57
65
 
58
66
  ---
59
67
 
60
- #### `initialize_all(principal, tenant)`
68
+ #### `set_context(principal, tenant)`
61
69
 
62
- Call `port.initialize(PluginContext)` on every registered port in registration order. Rolls back (shuts down already-initialised ports) if any port raises.
70
+ Set the `principal`/`tenant` threaded through every port's `PluginContext` in `initialize_all()`. Call before `initialize_all()`, typically right after all `register()` calls.
63
71
 
64
72
  **Parameters:**
65
73
 
66
74
  | Name | Type | Default | Description |
67
75
  |---|---|---|---|
68
- | `principal` | `PrincipalContext \| None` | `None` | Optional caller identity threaded into `PluginContext` |
69
- | `tenant` | `TenantContext \| None` | `None` | Optional tenant identity threaded into `PluginContext` |
76
+ | `principal` | `PrincipalContext \| None` | `None` | Optional caller identity threaded into every port's `PluginContext` |
77
+ | `tenant` | `TenantContext \| None` | `None` | Optional tenant identity threaded into every port's `PluginContext` |
78
+
79
+ ---
80
+
81
+ #### `initialize_all()`
82
+
83
+ Call `port.initialize(PluginContext)` on every registered port in registration order. Each port's config comes from its own `register()` call; `principal`/`tenant` come from `set_context()` (both default to `None` if never set). Each call is bounded by that port's `init_timeout` (or the registry's `default_init_timeout` if the port didn't specify one) — unbounded by default. Rolls back (shuts down already-initialised ports, in reverse order) if any port raises or times out, then re-raises.
70
84
 
71
- **Raises:** `PluginInitializationError` — wrapping the original exception from `port.initialize()`, after rolling back.
85
+ **Raises:** The exception raised by the failing port's `initialize()` — or a plain `TimeoutError` if its timeout elapsed first. Neither is wrapped into a `PluginError` subclass; the registry re-raises whatever `initialize()` raised (or `TimeoutError`) unchanged, after rollback.
72
86
 
73
87
  ---
74
88
 
@@ -79,7 +79,7 @@ Called by `PluginRegistry.shutdown_all()` in LIFO order. Should flush, close con
79
79
 
80
80
  #### `health()`
81
81
 
82
- Return a liveness/readiness snapshot. Must never raise — return `PluginHealth(status=PluginStatus.UNAVAILABLE, message=...)` on any failure.
82
+ Return a liveness/readiness snapshot. Must never raise — return `PluginHealth(status=PluginStatus.FAILED, message=...)` on any failure.
83
83
 
84
84
  **Returns:** `PluginHealth`
85
85
 
@@ -144,12 +144,19 @@ See [capability-taxonomy.md](../../technical/architecture/capability-taxonomy.md
144
144
  ### `PluginStatus`
145
145
 
146
146
  ```python
147
- class PluginStatus(str, Enum):
148
- READY = "ready"
149
- DEGRADED = "degraded"
150
- UNAVAILABLE = "unavailable"
147
+ class PluginStatus(Enum):
148
+ DISCOVERED = auto()
149
+ REGISTERED = auto()
150
+ CONFIGURED = auto()
151
+ INITIALIZED = auto()
152
+ READY = auto()
153
+ STOPPING = auto()
154
+ STOPPED = auto()
155
+ FAILED = auto()
151
156
  ```
152
157
 
158
+ Lifecycle status values a port/plugin transitions through during application startup and shutdown; the registry tracks the last known state. There is no separate "degraded" or "unavailable" member — a health check that cannot confirm full health reports `FAILED` and encodes the nuance in `PluginHealth.message`/`.details`.
159
+
153
160
  ---
154
161
 
155
162
  ### `PluginHealth`
@@ -158,11 +165,11 @@ class PluginStatus(str, Enum):
158
165
  @dataclass(frozen=True)
159
166
  class PluginHealth:
160
167
  status: PluginStatus
161
- message: str | None = None
168
+ message: str = ""
162
169
  details: dict[str, Any] = field(default_factory=dict)
163
170
  ```
164
171
 
165
- Returned by `Lifecycle.health()`. Carries a status, an optional human-readable message, and optional structured detail (e.g. latency, schema checks).
172
+ Returned by `Lifecycle.health()`. Carries a status, a human-readable message (empty string by default, not `None`), and optional structured detail (e.g. latency, schema checks). Frozen — safe to cache and pass across coroutines.
166
173
 
167
174
  ```python
168
175
  # Minimal
@@ -174,12 +181,16 @@ PluginHealth(
174
181
  details={"ping_ms": 2.1, "schema_ok": True},
175
182
  )
176
183
 
177
- # Degraded
184
+ # Degraded but still serving — no separate status exists; encode the
185
+ # nuance in message/details while status stays READY
178
186
  PluginHealth(
179
- status=PluginStatus.DEGRADED,
187
+ status=PluginStatus.READY,
180
188
  message="replica lag 5s",
181
189
  details={"lag_s": 5},
182
190
  )
191
+
192
+ # Failed
193
+ PluginHealth(status=PluginStatus.FAILED, message=str(exc))
183
194
  ```
184
195
 
185
196
  ---
@@ -189,12 +200,13 @@ PluginHealth(
189
200
  ```python
190
201
  @dataclass(frozen=True)
191
202
  class PluginContext:
192
- config: dict[str, Any] = field(default_factory=dict)
203
+ config: Mapping[str, Any]
204
+ plugin_name: str
193
205
  principal: PrincipalContext | None = None
194
206
  tenant: TenantContext | None = None
195
207
  ```
196
208
 
197
- Passed to `Lifecycle.initialize()` by `PluginRegistry.initialize_all()`. Carries the port's config dict plus optional identity/tenancy context.
209
+ Passed to `Lifecycle.initialize()` by `PluginRegistry.initialize_all()`. Carries the port's own validated config (a read-only mapping — an empty one when no config was registered for this port), the port's registered `name`, and optional identity/tenancy context. Intentionally narrow on cross-port dependencies — a port cannot look up other ports from this context; the registry must order registrations so dependency ports initialize first.
198
210
 
199
211
  ---
200
212
 
@@ -204,11 +216,11 @@ Passed to `Lifecycle.initialize()` by `PluginRegistry.initialize_all()`. Carries
204
216
  @dataclass(frozen=True)
205
217
  class PrincipalContext:
206
218
  principal_id: str
207
- roles: frozenset[str] = field(default_factory=frozenset)
208
- attributes: dict[str, Any] = field(default_factory=dict)
219
+ roles: tuple[str, ...] = ()
220
+ claims: dict[str, object] = field(default_factory=dict)
209
221
  ```
210
222
 
211
- Frozen dataclass carrying caller identity. Threaded through both `PluginContext` (outbound init) and `RequestContext` (inbound requests).
223
+ Frozen dataclass carrying caller identity (user, service account, API key). `roles` is empty when the caller has no roles or role information is unavailable; `claims` carries additional verified claims from the auth token/session (e.g. `{"email": "...", "org": "..."}`). Threaded through both `PluginContext` (outbound init) and `RequestContext` (inbound requests).
212
224
 
213
225
  ---
214
226
 
@@ -218,10 +230,10 @@ Frozen dataclass carrying caller identity. Threaded through both `PluginContext`
218
230
  @dataclass(frozen=True)
219
231
  class TenantContext:
220
232
  tenant_id: str
221
- attributes: dict[str, Any] = field(default_factory=dict)
233
+ name: str = ""
222
234
  ```
223
235
 
224
- Frozen dataclass carrying tenant identity. Threaded through both `PluginContext` and `RequestContext`.
236
+ Frozen dataclass carrying tenant identity — `name` is empty when only the identifier is known. Threaded through both `PluginContext` and `RequestContext`.
225
237
 
226
238
  ---
227
239
 
@@ -415,7 +427,7 @@ class PostgresItemRepository:
415
427
  await self._pool.fetchval("SELECT 1")
416
428
  return PluginHealth(status=PluginStatus.READY)
417
429
  except Exception as exc:
418
- return PluginHealth(status=PluginStatus.UNAVAILABLE, message=str(exc))
430
+ return PluginHealth(status=PluginStatus.FAILED, message=str(exc))
419
431
 
420
432
  # Domain methods
421
433
  async def get(self, entity_id: str) -> Item | None: ...
@@ -0,0 +1,170 @@
1
+ # runtime
2
+
3
+ `openframe/core/runtime/` · The recommended composition root, at three levels of ceremony.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ `ApplicationBootstrap` is the single recommended composition root for application code — not one of several competing wiring patterns. It manages `PluginRegistry` registration and lifecycle for you, as an async context manager: `__aenter__` calls `start()` (which calls `configure()` then `initialize_all()`), and `__aexit__` calls `stop()` (which calls `shutdown_all()` then `shutdown_telemetry()`).
10
+
11
+ It's used at three levels of ceremony, not three separate patterns — see [Choosing a Wiring Pattern](../../developer-guide/how-it-works.md#choosing-a-wiring-pattern) for the full guide:
12
+
13
+ 1. `ApplicationBootstrap.compose(*ports)` — no subclass, for one or a few ports.
14
+ 2. Subclass + `configure()` — for per-port `config`/`init_timeout` or conditional registration.
15
+ 3. `bootstrap.registry` — the escape hatch to the underlying `PluginRegistry` for what tiers 1-2 don't cover.
16
+
17
+ ```python
18
+ from openframe.core.runtime import ApplicationBootstrap
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Classes
24
+
25
+ ### `ApplicationBootstrap`
26
+
27
+ ```python
28
+ class ApplicationBootstrap:
29
+ def __init__(self, *, default_init_timeout: float | None = None) -> None: ...
30
+ def configure(self) -> None: ...
31
+ def register(
32
+ self,
33
+ plugin: BasePort,
34
+ *,
35
+ config: Mapping[str, Any] | None = None,
36
+ init_timeout: float | None = None,
37
+ ) -> None: ...
38
+ def get(self, capability: Capability) -> BasePort: ...
39
+ def get_all(self, capability: Capability) -> list[BasePort]: ...
40
+ @property
41
+ def registry(self) -> PluginRegistry: ...
42
+ @classmethod
43
+ def compose(cls, *ports: BasePort) -> "ApplicationBootstrap": ...
44
+ async def start(self) -> None: ...
45
+ async def stop(self) -> None: ...
46
+ async def health(self) -> dict[str, PluginHealth]: ...
47
+
48
+ async def __aenter__(self) -> "ApplicationBootstrap": ...
49
+ async def __aexit__(self, *args: object) -> None: ...
50
+ ```
51
+
52
+ ---
53
+
54
+ #### `compose(*ports)` — classmethod
55
+
56
+ The zero-ceremony entry point. Builds an `ApplicationBootstrap` and registers each given port, in order, with no subclass. Covers the common case: settings are usually supplied at a port's own construction (e.g. `PostgresRepository(settings)`), not threaded through `PluginContext.config` — none of the four real `openframe-adapters` packages (Postgres, Mongo, Redis, Kafka) actually read `PluginContext.config` at all. Use a subclass with `configure()` instead once a port needs `config=`, `init_timeout=`, or registration order that depends on a runtime condition.
57
+
58
+ ```python
59
+ async with ApplicationBootstrap.compose(PostgresRepository(settings)) as app:
60
+ repo = app.get(Capability.PERSISTENCE)
61
+ ```
62
+
63
+ ---
64
+
65
+ #### `configure()`
66
+
67
+ Override this method to register ports. Called by `start()` before `initialize_all()`. The default implementation is a no-op — `compose()` never overrides it; it registers ports immediately at construction instead.
68
+
69
+ ```python
70
+ class MyApp(ApplicationBootstrap):
71
+ def configure(self) -> None:
72
+ self.register(PostgresItemRepository(), config={"dsn": "postgres://..."})
73
+ self.register(RedisCache(), config={"url": "redis://..."})
74
+ ```
75
+
76
+ ---
77
+
78
+ #### `start()`
79
+
80
+ Call `configure()` then `registry.initialize_all()`. If any port's `initialize()` raises (or its `init_timeout` elapses first), that exception propagates **unwrapped** after already-initialized ports are rolled back — `start()` does not wrap it into a `PluginInitializationError` (that class is currently unused anywhere in `openframe-core`).
81
+
82
+ ---
83
+
84
+ #### `stop()`
85
+
86
+ Call `registry.shutdown_all()`, then flush and shut down the OTel SDK via `shutdown_telemetry()`. Never raises — port and telemetry errors are both logged and shutdown continues.
87
+
88
+ ---
89
+
90
+ #### `register(plugin, config, init_timeout)`
91
+
92
+ Delegate to `registry.register()`. Raises `TypeError` if the object doesn't satisfy `BasePort`, or `DuplicatePluginError` if a port with this name is already registered. `init_timeout` overrides the bootstrap's own `default_init_timeout` (set on `__init__`) for this one port; `None` (the default) falls back to it.
93
+
94
+ ---
95
+
96
+ #### `get(capability)`
97
+
98
+ Delegate to `registry.get()` — strict, raises `AmbiguousCapabilityError` on >1 match. Only valid after `start()` has completed.
99
+
100
+ ---
101
+
102
+ #### `get_all(capability)`
103
+
104
+ Delegate to `registry.get_all()` — the escape hatch for the deliberate multi-port-per-capability case (e.g. primary + replica) that `get()`'s strictness rejects. Returns an empty list, never raises, when nothing matches.
105
+
106
+ ---
107
+
108
+ #### `registry` — property
109
+
110
+ The underlying `PluginRegistry` instance. For customization neither `compose()` nor a `configure()` subclass covers (e.g. `list_plugins()`), without standing up a second, parallel registry outside `ApplicationBootstrap`.
111
+
112
+ ---
113
+
114
+ #### `health()`
115
+
116
+ Delegate to `registry.health_all()` — a live health snapshot (dict of port name → `PluginHealth`) for every registered port.
117
+
118
+ ---
119
+
120
+ ## Usage Patterns
121
+
122
+ ### Zero-ceremony — one or a few ports
123
+
124
+ ```python
125
+ from openframe.core.runtime import ApplicationBootstrap
126
+ from openframe.core.ports import Capability
127
+
128
+ async with ApplicationBootstrap.compose(PostgresItemRepository()) as bootstrap:
129
+ repo = bootstrap.get(Capability.PERSISTENCE)
130
+ await serve(ItemService(repo))
131
+ ```
132
+
133
+ ### Subclass — per-port config, or conditional registration
134
+
135
+ ```python
136
+ class MyServiceBootstrap(ApplicationBootstrap):
137
+ def configure(self) -> None:
138
+ self.register(PostgresItemRepository(), config={"dsn": "..."})
139
+
140
+ async with MyServiceBootstrap() as bootstrap:
141
+ repo = bootstrap.get(Capability.PERSISTENCE)
142
+ await serve(ItemService(repo))
143
+ # shutdown_all() then shutdown_telemetry() called automatically on __aexit__
144
+ ```
145
+
146
+ ### As a FastAPI lifespan
147
+
148
+ ```python
149
+ from contextlib import asynccontextmanager
150
+ from fastapi import FastAPI
151
+
152
+ bootstrap = ApplicationBootstrap.compose(PostgresItemRepository())
153
+
154
+ @asynccontextmanager
155
+ async def lifespan(app: FastAPI):
156
+ setup_telemetry()
157
+ await bootstrap.start()
158
+ yield
159
+ await bootstrap.stop()
160
+
161
+ app = FastAPI(lifespan=lifespan)
162
+ ```
163
+
164
+ ### The registry escape hatch — multi-port-per-capability
165
+
166
+ ```python
167
+ async with ApplicationBootstrap.compose(PrimaryPostgres(), ReplicaPostgres()) as bootstrap:
168
+ primary, replica = bootstrap.get_all(Capability.PERSISTENCE)
169
+ # or, equivalently: bootstrap.registry.get_all(Capability.PERSISTENCE)
170
+ ```
@@ -45,22 +45,25 @@ except OpenFrameError:
45
45
 
46
46
  ---
47
47
 
48
- ## PluginRegistry Failures
48
+ ## PluginRegistry / ApplicationBootstrap Failures
49
49
 
50
- **`PluginNotFoundError`** — no port registered for the requested `Capability`. Verify `registry.register(port)` was called before `registry.get(Capability.X)`.
50
+ These apply identically whether you're using `PluginRegistry` directly or through `ApplicationBootstrap` (`app.get(...)`, `app.start()`) — the bootstrap delegates straight through.
51
51
 
52
- **`AmbiguousCapabilityError`** — more than one port registered for the capability passed to `get()`. Use `registry.get_all(Capability.X)` if multiple same-capability ports is intentional.
52
+ **`KeyError`** — no port registered for the requested `Capability`. Verify `register(port)` was called before `get(Capability.X)`. (Note: despite the exception hierarchy also defining a `PluginNotFoundError`, `get()` actually raises a plain `KeyError` — not that class.)
53
53
 
54
- **`PluginInitializationError`** — a port's `initialize()` raised. The registry rolls back already-initialised ports before re-raising. Check the `cause` for the original error.
54
+ **`AmbiguousCapabilityError`** — more than one port registered for the capability passed to `get()`. Use `get_all(Capability.X)` if multiple same-capability ports is intentional (`ApplicationBootstrap.get_all()` or `registry.get_all()`).
55
+
56
+ **Whatever `initialize()` raised** — `initialize_all()` does **not** wrap a failing port's exception into a `PluginInitializationError` (that class exists in the exception hierarchy but nothing in `openframe-core` currently raises it). It rolls back already-initialised ports (LIFO) and then re-raises the original exception from `initialize()` completely unchanged — catch whatever your adapter actually raises, or `OpenFrameError` for the ecosystem-wide catch point if your adapters follow that convention:
55
57
 
56
58
  ```python
57
- from openframe.core.exceptions import PluginInitializationError
59
+ from openframe.core.exceptions import OpenFrameError
58
60
 
59
61
  try:
60
- await registry.initialize_all()
61
- except PluginInitializationError as exc:
62
- print(exc.plugin_name) # which port failed
63
- print(exc.cause) # original exception from initialize()
62
+ await app.start() # or: await registry.initialize_all()
63
+ except OpenFrameError as exc:
64
+ print(exc.code, exc.retryable) # whatever the failing port's initialize() raised
65
+ except TimeoutError:
66
+ print("a port's init_timeout elapsed — see PluginRegistry(default_init_timeout=...)")
64
67
  ```
65
68
 
66
69
  ---
@@ -90,6 +90,8 @@ print(isinstance(exc, OpenFrameError)) # True — single catch point for the e
90
90
 
91
91
  ## Explore PluginRegistry
92
92
 
93
+ `PluginRegistry` is the underlying mechanism — useful to poke at directly here to see how it works. In application code, use `ApplicationBootstrap` instead (`ApplicationBootstrap.compose(*ports)` for the common case) — see [How It Works § Choosing a Wiring Pattern](how-it-works.md#choosing-a-wiring-pattern).
94
+
93
95
  ```python
94
96
  import asyncio
95
97
  from openframe.core.ports import Capability