openframe-core 3.2.0__tar.gz → 3.3.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 (149) hide show
  1. {openframe_core-3.2.0 → openframe_core-3.3.0}/.github/CHANGELOG.md +115 -8
  2. {openframe_core-3.2.0 → openframe_core-3.3.0}/.gitignore +1 -0
  3. {openframe_core-3.2.0 → openframe_core-3.3.0}/PKG-INFO +2 -2
  4. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/health.md +4 -3
  5. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/plugins.md +27 -13
  6. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/ports.md +29 -17
  7. openframe_core-3.3.0/docs/code/modules/runtime.md +170 -0
  8. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/developer-guide/debugging.md +12 -9
  9. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/developer-guide/explore.md +2 -0
  10. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/developer-guide/how-it-works.md +45 -1
  11. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/developer-guide/quick-start/index.md +21 -10
  12. openframe_core-3.3.0/docs/technical/architecture/adrs/adr-007-schema-contracts.md +111 -0
  13. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/adrs/index.md +2 -0
  14. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/design-decisions.md +37 -35
  15. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/package-journey/adapter-lifecycle.md +2 -2
  16. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/runbooks/adapter-connection-failure.md +4 -4
  17. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/runbooks/postgres-pool-exhausted.md +1 -1
  18. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/runbooks/queue-consumer-stuck.md +2 -2
  19. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/runbooks/redis-connection-lost.md +1 -1
  20. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/overview/glossary.md +2 -2
  21. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/overview/roadmap.md +39 -8
  22. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/exceptions/plugin.py +1 -0
  23. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/plugins/registry.py +51 -6
  24. openframe_core-3.3.0/openframe/core/runtime/bootstrap.py +295 -0
  25. openframe_core-3.3.0/openframe/core/schemas/__init__.py +128 -0
  26. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/tracing/proxy.py +2 -2
  27. {openframe_core-3.2.0 → openframe_core-3.3.0}/pyproject.toml +1 -1
  28. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_exceptions.py +5 -0
  29. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_plugins.py +89 -0
  30. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_runtime.py +131 -0
  31. openframe_core-3.3.0/tests/test_schemas.py +91 -0
  32. openframe_core-3.2.0/docs/code/modules/runtime.md +0 -122
  33. openframe_core-3.2.0/openframe/core/runtime/bootstrap.py +0 -196
  34. {openframe_core-3.2.0 → openframe_core-3.3.0}/.github/requirements/docs.txt +0 -0
  35. {openframe_core-3.2.0 → openframe_core-3.3.0}/.github/requirements/test.txt +0 -0
  36. {openframe_core-3.2.0 → openframe_core-3.3.0}/.github/workflows/app-test.yml +0 -0
  37. {openframe_core-3.2.0 → openframe_core-3.3.0}/.github/workflows/auto-docs.yml +0 -0
  38. {openframe_core-3.2.0 → openframe_core-3.3.0}/.github/workflows/python-build.yml +0 -0
  39. {openframe_core-3.2.0 → openframe_core-3.3.0}/.vscode/settings.json +0 -0
  40. {openframe_core-3.2.0 → openframe_core-3.3.0}/LICENSE +0 -0
  41. {openframe_core-3.2.0 → openframe_core-3.3.0}/README.md +0 -0
  42. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/assets/images/banner.png +0 -0
  43. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/assets/images/favicon.png +0 -0
  44. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/assets/images/logo-dark.png +0 -0
  45. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/assets/images/logo-light.png +0 -0
  46. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/assets/javascripts/extra.js +0 -0
  47. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/assets/stylesheets/extra.css +0 -0
  48. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/cicd/pipeline.md +0 -0
  49. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/getting-started/index.md +0 -0
  50. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/config.md +0 -0
  51. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/exceptions.md +0 -0
  52. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/inbound.md +0 -0
  53. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/index.md +0 -0
  54. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/middleware.md +0 -0
  55. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/telemetry.md +0 -0
  56. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/testing.md +0 -0
  57. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/code/modules/tracing.md +0 -0
  58. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/developer-guide/first-change.md +0 -0
  59. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/developer-guide/local-dev.md +0 -0
  60. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/index.md +0 -0
  61. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/adrs/adr-001-namespace.md +0 -0
  62. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/adrs/adr-002-hexagonal.md +0 -0
  63. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/adrs/adr-003-async.md +0 -0
  64. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/adrs/adr-004-pydantic.md +0 -0
  65. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/adrs/adr-005-middleware.md +0 -0
  66. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/adrs/adr-006-unified-port-lifecycle.md +0 -0
  67. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/capability-taxonomy.md +0 -0
  68. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/component-lifecycle.md +0 -0
  69. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/data-flows.md +0 -0
  70. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/error-taxonomy.md +0 -0
  71. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/package-journey/error-propagation.md +0 -0
  72. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/package-journey/index.md +0 -0
  73. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/package-journey/template-wiring.md +0 -0
  74. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/package-journey/tracing-flow.md +0 -0
  75. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/architecture/system-architecture.md +0 -0
  76. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/deployment/cicd.md +0 -0
  77. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/deployment/environments.md +0 -0
  78. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/deployment/secrets-and-config.md +0 -0
  79. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/infrastructure/networking.md +0 -0
  80. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/infrastructure/platform.md +0 -0
  81. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/infrastructure/storage.md +0 -0
  82. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/index.md +0 -0
  83. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/monitoring.md +0 -0
  84. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/runbooks/modal-cold-start-spike.md +0 -0
  85. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/operations/runbooks/telemetry-export-failing.md +0 -0
  86. {openframe_core-3.2.0 → openframe_core-3.3.0}/docs/technical/overview/system-overview.md +0 -0
  87. {openframe_core-3.2.0 → openframe_core-3.3.0}/mkdocs.yml +0 -0
  88. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/__init__.py +0 -0
  89. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/__init__.py +0 -0
  90. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/config/__init__.py +0 -0
  91. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/config/settings.py +0 -0
  92. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/exceptions/__init__.py +0 -0
  93. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/exceptions/adapter.py +0 -0
  94. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/exceptions/base.py +0 -0
  95. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/exceptions/codes.py +0 -0
  96. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/inbound/__init__.py +0 -0
  97. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/inbound/command.py +0 -0
  98. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/inbound/context.py +0 -0
  99. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/inbound/query.py +0 -0
  100. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/inbound/usecase.py +0 -0
  101. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/middleware/__init__.py +0 -0
  102. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/middleware/telemetry.py +0 -0
  103. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/middleware/types.py +0 -0
  104. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/plugins/__init__.py +0 -0
  105. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/__init__.py +0 -0
  106. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/capability.py +0 -0
  107. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/context.py +0 -0
  108. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/health.py +0 -0
  109. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/identity.py +0 -0
  110. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/lifecycle.py +0 -0
  111. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/outbound/__init__.py +0 -0
  112. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/outbound/consumer.py +0 -0
  113. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/outbound/producer.py +0 -0
  114. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/outbound/repository.py +0 -0
  115. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/ports/port.py +0 -0
  116. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/runtime/__init__.py +0 -0
  117. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/telemetry/__init__.py +0 -0
  118. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/telemetry/setup.py +0 -0
  119. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/__init__.py +0 -0
  120. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/contracts/__init__.py +0 -0
  121. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/contracts/consumer.py +0 -0
  122. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/contracts/inbound.py +0 -0
  123. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/contracts/lifecycle.py +0 -0
  124. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/contracts/port.py +0 -0
  125. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/contracts/producer.py +0 -0
  126. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/contracts/repository.py +0 -0
  127. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/fakes/__init__.py +0 -0
  128. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/fakes/consumer.py +0 -0
  129. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/fakes/inbound.py +0 -0
  130. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/fakes/producer.py +0 -0
  131. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/fakes/repository.py +0 -0
  132. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/testing/fixtures.py +0 -0
  133. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/tracing/__init__.py +0 -0
  134. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/core/tracing/propagation.py +0 -0
  135. {openframe_core-3.2.0 → openframe_core-3.3.0}/openframe/py.typed +0 -0
  136. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/conftest.py +0 -0
  137. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_config.py +0 -0
  138. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_inbound.py +0 -0
  139. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_inbound_contracts.py +0 -0
  140. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_middleware.py +0 -0
  141. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_middleware_types.py +0 -0
  142. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_ports.py +0 -0
  143. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_ports_primitives.py +0 -0
  144. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_record_error.py +0 -0
  145. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_shutdown_telemetry.py +0 -0
  146. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_telemetry_sidecar.py +0 -0
  147. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_testing_fakes.py +0 -0
  148. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_tracing.py +0 -0
  149. {openframe_core-3.2.0 → openframe_core-3.3.0}/tests/test_tracing_propagation.py +0 -0
@@ -9,17 +9,49 @@ 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.3.0 — 2026-09-28
20
15
 
21
16
  ### Added
22
17
 
18
+ - **`ApplicationBootstrap.compose(*ports)`, `.get_all()`, `.registry`** —
19
+ resolves the long-standing "three wiring options" ambiguity (previously
20
+ tracked here as an architectural watch item and in the roadmap) by
21
+ consolidating to one recommended class at three levels of ceremony
22
+ instead of three competing patterns:
23
+ - `compose(*ports)` (new classmethod) — zero-subclass entry point for a
24
+ service with one or a few ports and no per-port `config`/`init_timeout`.
25
+ Registers each port immediately at construction; `configure()` stays
26
+ the default no-op.
27
+ - `get_all(capability)` (new method) — mirrors `PluginRegistry.get_all()`,
28
+ closing the one real gap that previously forced a multi-port-per-capability
29
+ setup (e.g. primary + replica) off `ApplicationBootstrap` and onto
30
+ `PluginRegistry` directly.
31
+ - `registry` (new read-only property) — exposes the underlying
32
+ `PluginRegistry` for customization neither of the above covers (e.g.
33
+ `list_plugins()`), without standing up a second, parallel registry.
34
+
35
+ `PluginRegistry` direct construction and `deps.py` + `lru_cache` are no
36
+ longer documented as peer alternatives to `ApplicationBootstrap` — see
37
+ `docs/technical/architecture/design-decisions.md` and
38
+ `docs/developer-guide/how-it-works.md`'s new "Choosing a Wiring Pattern"
39
+ section for the full resolution.
40
+
41
+ - **`PluginRegistry`/`ApplicationBootstrap` per-port init timeout** —
42
+ `PluginRegistry(default_init_timeout=...)` and
43
+ `PluginRegistry.register(..., init_timeout=...)` (mirrored on
44
+ `ApplicationBootstrap`'s constructor and `register()`) bound how long
45
+ `initialize_all()` waits for a single port's `initialize()` before
46
+ giving up. Both default to `None` (no timeout), preserving prior
47
+ behaviour exactly for callers who don't opt in. Addresses a real
48
+ production risk: previously, one adapter's `initialize()` hanging
49
+ (e.g. a TCP connection that completes the handshake but never
50
+ responds) would block application startup indefinitely with no way
51
+ to bound it. A timeout surfaces as a plain `TimeoutError` and is
52
+ handled identically to any other `initialize()` failure — already-
53
+ initialized ports are rolled back (LIFO) before it propagates.
54
+
23
55
  - **`openframe.core.telemetry.shutdown_telemetry()`** — flushes and shuts
24
56
  down the OTel SDK, ensuring the `BatchSpanProcessor` exports all buffered
25
57
  spans before process exit. Idempotent, never raises, resets `_INITIALISED`
@@ -37,6 +69,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
37
69
 
38
70
  ### Fixed
39
71
 
72
+ #### Code
73
+
74
+ - `TracingProxy` (`tracing/proxy.py`) — replaced the deprecated
75
+ `asyncio.iscoroutinefunction()` sync/async probe with
76
+ `inspect.iscoroutinefunction()`. `asyncio.iscoroutinefunction` is
77
+ slated for removal in Python 3.16; this was the only deprecation
78
+ warning in the test suite.
79
+ - `AmbiguousCapabilityError` (`exceptions/plugin.py`) — its `capability`
80
+ and `matches` constructor arguments are now also mirrored into
81
+ `self.context`, matching the pattern every other `AdapterError`/
82
+ `PluginError` subclass already follows (e.g. `AdapterError` mirrors
83
+ `adapter`/`operation` into `context`). Previously these two fields
84
+ were set as plain attributes but silently absent from `context`.
85
+
86
+ #### Documentation
87
+
88
+ - Corrected `PluginStatus` across `docs/technical/overview/glossary.md`,
89
+ `docs/code/modules/ports.md`, `docs/code/modules/health.md`, and four
90
+ operational runbooks — several described a `READY`/`DEGRADED`/
91
+ `UNAVAILABLE` enum that has never existed in source. The actual enum
92
+ (`ports/health.py`) is `DISCOVERED`/`REGISTERED`/`CONFIGURED`/
93
+ `INITIALIZED`/`READY`/`STOPPING`/`STOPPED`/`FAILED`; a failed health
94
+ check reports `FAILED`, with finer detail carried in
95
+ `PluginHealth.message`/`.details`, not a dedicated status value.
96
+ - Corrected the `PluginContext`, `PrincipalContext`, and `TenantContext`
97
+ examples in `docs/code/modules/ports.md` — the shown `PluginContext`
98
+ was missing the required `plugin_name` field entirely and typed
99
+ `config` as `dict` instead of `Mapping`; `PrincipalContext`/
100
+ `TenantContext` showed fields (`attributes`, `roles: frozenset[str]`)
101
+ that don't exist on the real dataclasses (`claims`,
102
+ `roles: tuple[str, ...]`, `name: str`).
103
+ - Added [ADR-007](../docs/technical/architecture/adrs/adr-007-schema-contracts.md)
104
+ documenting the `openframe.core.schemas` `@contract` marker shipped in
105
+ v3.2.1 — the module's own docstring already cited "ADR-007" before
106
+ this record existed.
107
+ - Corrected `docs/code/modules/plugins.md` and `docs/code/modules/runtime.md`,
108
+ both stale independently of the init-timeout addition above:
109
+ `initialize_all()` was documented as taking `principal`/`tenant`
110
+ parameters directly (they're actually set separately via
111
+ `PluginRegistry.set_context()`, undocumented until now); `ApplicationBootstrap`
112
+ was documented as having a `get_all()` method that did not exist on
113
+ that class at the time (only on `PluginRegistry` itself) — since
114
+ resolved by actually adding `get_all()` to `ApplicationBootstrap`, see
115
+ the wiring-consolidation entry above; `ApplicationBootstrap.health()`
116
+ — a real method — was undocumented entirely; and `start()`/`initialize_all()`
117
+ were both documented as raising a wrapped `PluginInitializationError`,
118
+ when in fact the registry re-raises whatever `initialize()` raised
119
+ (or a plain `TimeoutError`) completely unwrapped.
120
+ - Corrected `docs/developer-guide/debugging.md`'s "PluginRegistry Failures"
121
+ section, which repeated the same `PluginInitializationError` inaccuracy
122
+ above, and additionally claimed `get()` raises `PluginNotFoundError` for
123
+ a missing capability — it actually raises a plain `KeyError`.
124
+ `PluginNotFoundError` and `PluginInitializationError` both exist in the
125
+ public exception hierarchy (`openframe.core.exceptions`) but neither is
126
+ currently raised anywhere in `openframe-core`'s own source — flagged as
127
+ a roadmap item (dead-code exception classes), not fixed here, since
128
+ making them actually get raised would be a behavioral change.
129
+
40
130
  #### CI/CD
41
131
 
42
132
  - Upgraded all GitHub Actions to Node.js 24-compatible versions, resolving
@@ -58,6 +148,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
58
148
 
59
149
  ---
60
150
 
151
+ ## v3.2.1
152
+
153
+ ### Added
154
+
155
+ - `openframe.core.schemas` — new experimental module providing
156
+ the `@contract(name, version)` decorator, `ContractMeta` dataclass,
157
+ and `get_contract_meta()` helper. Marks Pydantic models as
158
+ published cross-service contracts. The schema export, registry,
159
+ breaking-change diff, and codegen machinery lives in the
160
+ separate `openframe-schemas` package (openframe-tooling repo).
161
+
162
+ ---
163
+
61
164
  ## v3.2.0
62
165
 
63
166
  ### Added
@@ -282,6 +385,10 @@ Dev dependencies: `pytest>=8.0`, `pytest-asyncio>=0.23`, `httpx>=0.27`.
282
385
 
283
386
  ---
284
387
 
285
- [Unreleased]: https://github.com/openframe-org/openframe-core/compare/v3.0.0...HEAD
388
+ [Unreleased]: https://github.com/openframe-org/openframe-core/compare/v3.3.0...HEAD
389
+ [3.3.0]: https://github.com/openframe-org/openframe-core/compare/v3.2.1...v3.3.0
390
+ [3.2.1]: https://github.com/openframe-org/openframe-core/compare/v3.2.0...v3.2.1
391
+ [3.2.0]: https://github.com/openframe-org/openframe-core/compare/v3.1.0...v3.2.0
392
+ [3.1.0]: https://github.com/openframe-org/openframe-core/compare/v3.0.0...v3.1.0
286
393
  [3.0.0]: https://github.com/openframe-org/openframe-core/compare/v1.0.0...v3.0.0
287
394
  [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.0
3
+ Version: 3.3.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