ADR-006 — Unified Port + Lifecycle Contract¶
Status: Accepted — v3.0.0 Supersedes: the port/health/plugin portions of ADR-002 — Hexagonal Architecture as the Universal Spine and the "seven-module" framing it implied.
Context¶
openframe-core v1.0–v2.0 grew three separate, disconnected contract families:
ports/—BaseRepository[T],BaseProducer[T],BaseConsumer[T]. Hardcoded, structuralProtocols with domain methods only. Noname, noversion, noinitialize/shutdown, no way to ask "is this thing healthy" without a second, unrelated protocol.health/—HealthCheck, a standaloneProtocolwithping()/is_ready(). Adapters that wanted both a port and a health check had to satisfy two unrelated structural protocols with no shared vocabulary (ping/is_readyvs. nothing).plugins/contracts.py—OpenFramePlugin, a third, independentProtocolwithname/version/capability+initialize/shutdown/health()returningPluginHealth. This is the protocol the plugin registry actually operates on. Adapters that wanted to be both aBaseRepositoryand participate in the plugin registry had to hand-write a wrapper class satisfyingOpenFramePluginthat delegated to the "real" port object — the "plugin wrapper tax" observed first-hand inopenframe-adapters-queue-kafka'sKafkaPluginwrapper around its own producer/consumer.
Three contract families for what is conceptually one idea — "a thing with an identity, that can be started, stopped, and asked if it's healthy, and that does some domain-specific work" — is unnecessary duplication. It also means capability lookups in the registry are keyed on a raw str (plugin.capability == "persistence"), with no static or runtime guarantee that adapters agree on the vocabulary.
The driving side of the hexagon (use cases, command/query handlers invoked by inbound adapters — HTTP routes, message handlers, CLI commands) had no first-class representation at all. openframe-core only modeled the driven/outbound side (ports/).
Decision¶
Replace all three contract families with one unified contract layer, openframe/core/contracts/ (merged into openframe/core/ports/ in v3.1.0 — see the addendum below), built from two small, composable primitives:
Identity—name: str,version: str,capability: Capability. What a thing is.Lifecycle—async initialize(context: PluginContext) -> None,async shutdown() -> None,async health() -> PluginHealth. How a thing is managed.health()is the single, canonical health primitive — it absorbs the oldHealthCheck.ping()(cheap liveness) andHealthCheck.is_ready()(full readiness) into one call that returns a richPluginHealthsnapshot (PluginStatus+ message + details) instead of two independent barebools. An adapter that wants to distinguish liveness from readiness encodes that distinction inPluginStatusandPluginHealth.details, not in a second method.BasePort(Identity, Lifecycle, Protocol)—@runtime_checkable. The single outbound base every port extends. Every port is lifecycle-aware by definition — there is no lifecycle-free port and noManaged*twin of a lifecycle-aware one.BaseRepository[T],BaseProducer[T], andBaseConsumer[T]are rebuilt in place asBasePortplus their own domain methods (Protocol[T]combined withBasePort).Capability(str, Enum)— a closed, typed taxonomy (PERSISTENCE,CACHE,QUEUE,SECRETS,FLAGS,STORAGE,TRANSPORT,INFERENCE,EMBEDDING,SCHEDULE,SEARCH) replacing rawstrcapability keys. Registry lookups (PluginRegistry.get/get_all) take aCapabilitymember, not a string. This gives IDEs, type-checkers, and code review a closed, discoverable vocabulary instead of adapters inventing ad hoc capability strings.
A "plugin" is no longer a distinct concept with its own protocol — a plugin is just a registered BasePort. There is nothing beyond BasePort to satisfy to participate in the registry. The wrapper tax this ADR eliminates: an adapter that is already a BaseRepository/BaseProducer/BaseConsumer needs zero additional code to also be a registrable plugin, because those ports are BasePorts.
The driving side of the hexagon gets first-class treatment in a new openframe/core/inbound/ module: UseCase[TInput, TOutput], CommandHandler[TInput], QueryHandler[TInput, TOutput], and RequestContext (correlation id + optional PrincipalContext/TenantContext). Inbound adapters (TelemetryMiddleware today, future protocol-package entrypoints) construct a RequestContext and pass it into a use case's execute(). This makes contracts the apex of the dependency graph, with both ports (outbound) and inbound (driving) depending on it — the hexagon now has two visible sides in the DAG, not one.
Identity/tenancy propagation, previously absent, is threaded structurally through the new context.py: PrincipalContext and TenantContext are frozen dataclasses. PluginContext (still the argument to Lifecycle.initialize) gains optional principal/tenant fields carrying them, and RequestContext (inbound side) carries the same two types. PluginRegistry.initialize_all() is updated to actually thread real config/principal/tenant through PluginContext instead of the previous hardcoded config={}.
What is removed¶
- The hardcoded, lifecycle-free
ports/protocols.BaseRepository,BaseProducer,BaseConsumerno longer exist as bare domain-method-onlyProtocols — they are rebuilt onBasePortin place. There is no lifecycle-free version left to import; this is not an additive change. - The standalone
HealthCheckprotocol and the entirehealth/package (health/protocol.py,health/__init__.py).ping()/is_ready()are gone. Health is exclusivelyLifecycle.health() -> PluginHealth. OpenFramePluginas a separate protocol.plugins/contracts.pyno longer defines its own duplicateIdentity/Lifecycle-shaped protocol.PluginStatus,PluginHealth, andPluginContextmove tocontracts/health.py— the one canonical home — andplugins/contracts.pyis deleted outright (see "Alternatives considered" below for why deletion was chosen over a re-export shim). All internal callers (plugins/__init__.py,plugins/registry.py,runtime/bootstrap.py) importBasePortand thecontractstypes directly.
There are no deprecated aliases and no backward-compatible shims for any of the above. This is an intentional, full clean break (see "Compatibility" below).
Consequences¶
- Dependency DAG changes.
contractsbecomes the apex module (afterexceptions → config). Bothportsand the newinboundmodule depend oncontracts;pluginsalso depends oncontractsdirectly (not through aplugins/contracts.pyduplicate).telemetry → tracing → middlewareis unaffected and remains a separate chain also rooted atcontracts(inbound adapters likeTelemetryMiddlewareconstructRequestContextfromcontracts.contexttypes). Thehealthnode is gone entirely — it was never a separate concern, only ever a subset ofLifecycle. See system-architecture.md for the updated DAG diagram. - Every port gains lifecycle. Any adapter implementing
BaseRepository/BaseProducer/BaseConsumermust now also implementname,version,capability,initialize,shutdown,health. This is the intended breaking change — adapters that were previously "just a port" become plugin-registrable for free, at the cost of implementing five more members.openframe-adapterspackages migrate on their own schedule because of the major version pin (see below). - Capability lookups become type-safe.
PluginRegistry.get(Capability.PERSISTENCE)replacesPluginRegistry.get("persistence"). Typos that previously produced a silent, hard-to-debugKeyErrorat runtime are now caught by static type checkers before the code ships. PluginRegistry.get()is strict. BecauseCapabilitylookups are now type-safe and because silently returning the first of several same-capability ports hides configuration mistakes,get()raises when more than oneBasePortis registered under the requested capability (useget_all()for the deliberate multi-adapter case, e.g. a primary + replica persistence pair). This is a new, stricter guarantee that did not exist for the old string-keyed lookup.- Contract test suites are restructured around
BasePort.testing/contracts/port.py(PortContractTests) andtesting/contracts/lifecycle.py(LifecycleContractTests) become the shared base every port-specific contract-test class (RepositoryContractTests,ProducerContractTests,ConsumerContractTests) builds on, replacing the old ad hoctest_satisfies_health_check_protocolblock.testing/fakes/*(InMemoryRepository,FakeProducer,FakeConsumer) are updated to satisfyBasePort— they gainname/version/capabilityandinitialize/shutdown/health, and loseping/is_ready. - Downstream compatibility is intentionally broken. No deprecated aliases exist for
HealthCheck,OpenFramePlugin, or the pre-v3 lifecycle-free ports. Version bumps2.0.0→3.0.0inpyproject.toml. Becauseopenframe-adapterspackages pinopenframe-corewith>=2.0,<3, they will not auto-resolve to v3 and will continue to function against v2.x until they are explicitly migrated to the new contract layer in their own repository (out of scope for this change — see the plan's "Out of scope" section).
Alternatives considered¶
- Keep
plugins/contracts.pyas a re-export shim (from openframe.core.contracts import ...re-exported under the old names) instead of deleting it outright. Rejected: ADR-006's own principle is "one contract family, one canonical home per concept." A re-export module is a second import path for the exact same type with no behavioral difference — it adds a file to maintain and a decision point for every future contributor ("do I import fromcontractsorplugins.contracts?") for zero benefit, since this is a deliberate breaking major version with no compatibility promise to preserve. Full deletion is more consistent with "no dual/back-compat approach." - Keep two health methods (
ping/is_ready) onLifecycleinstead of collapsing to onehealth(). Rejected: the two-level distinction is a reporting granularity concern, not a protocol shape concern — it is fully expressible asPluginStatusvalues andPluginHealth.details(e.g.PluginHealth(status=PluginStatus.READY, details={"ping_ms": 2.1, "schema_ok": True})) without needing two methods on every implementer. - Model
Capabilityas an openstrsubtype (NewTypeor plain constants module) instead of a closedEnum. Rejected: the plan explicitly calls for a typed enum, and a closed enum is what makesPluginRegistry.get_all()'s duplicate-guard and static analysis of "which capabilities exist" possible. Extending the taxonomy in a later minor version is a backward-compatible additive change (new enum member), consistent with normal enum evolution.
Addendum — Unified error hierarchy (v3.0.0)¶
The same "one contract family, one canonical home" principle applied to ports is applied to exceptions in this release.
Context¶
Errors were split across two packages: exceptions/ (AdapterError family)
and errors/ (PluginError family). Neither shared a common root, so there
was no single catch point for "anything openframe raised," and the two package
names (exceptions vs errors) were an arbitrary split for what are
conceptually siblings.
Decision¶
- Consolidate into a single
exceptions/package.errors/is deleted. The package keeps the ecosystem-pinnedopenframe.core.exceptionsimport path (only the newer plugin-error path moves), andexceptions/is the lowest layer of the DAG, so the shared root can live there without inverting any dependency. OpenFrameErrorroot. A single root every family derives from (AdapterError,PluginError, and every downstream package's family), soexcept OpenFrameErroris a catch point for the whole ecosystem. It carriescode,message,severity,retryable,correlation_id,context, andcause— semantics carried as data so upper layers reason over them without anisinstanceladder.- Typed, decentralised taxonomy.
ErrorCode/SeverityareStrEnums; codes follow adomain.kindconvention.ErrorCodeenumerates only core's codes — downstream packages declare their own plain-string codes rather than extending the enum, so the taxonomy grows without a central bottleneck. - Constructors preserved.
AdapterError(message, adapter, operation, cause)andPluginError(message, plugin_name)keep their exact signatures and__str__, so existing raise sites andexceptclauses are unchanged. Each subclass sets itscode(and, where meaningful,retryable) as a class attribute. - Errors flow into telemetry without importing it.
exceptionsstays at the bottom of the DAG and imports nothing upward.telemetry.record_error(which legally importsOpenFrameError) reads the error's data and records it — span attributes (error.code/severity/retryable), the exception event, acorrelation_idstamp-back, and a dedupedopenframe.error.countmetric. It is called at each boundary seam (TracingProxy,TelemetryMiddleware,PluginRegistrylifecycle). See error-taxonomy.md for the full reference.
Deferred¶
to_dict()/from_dict()serialisation for cross-service propagation. The fields are laid out to be serialisation-ready; the methods are added when theprotocol/clientpackages need them (a non-breaking addition).
Addendum — contracts/ merged into ports/, outbound/ sub-module introduced (v3.1.0)¶
Context¶
openframe.core.contracts (the port/lifecycle primitives: Identity,
Lifecycle, BasePort, Capability, PluginStatus/PluginHealth/
PluginContext, PrincipalContext/TenantContext) and
openframe.core.ports (the outbound protocols: BaseRepository,
BaseProducer, BaseConsumer) were two directories for what this ADR
already establishes as one concept — the port contract layer of the
hexagonal architecture. Every outbound protocol extends BasePort
directly; keeping the primitives and the protocols that compose them in
two separate top-level modules was an arbitrary split, not a meaningful
boundary.
Separately, BaseRepository/BaseProducer/BaseConsumer are
capability-specific outbound port protocols — what you get when the
BasePort primitives are applied to one outbound capability. They are
not implementations (those live in openframe-adapters), and sitting
as three loose files directly inside ports/ gave them no distinct
identity from the primitive modules they are built on, and no clear
intake point for future capability-specific protocols
(BaseSecretsProvider, BaseObjectStore, BaseFeatureFlagProvider
from openframe-infra).
Decision¶
- Merge
contracts/intoports/. All six primitive modules (capability.py,context.py,health.py,identity.py,lifecycle.py,port.py) move intoopenframe/core/ports/. A singleports/__init__.pyexports everything previously split across the two__init__.pyfiles. - Introduce
ports/outbound/. The three existing outbound protocol modules (repository.py,producer.py,consumer.py) move into a newopenframe/core/ports/outbound/sub-package, mirroring the existingopenframe/core/inbound/on the driving side of the hexagon. The nameoutbound/(notprotocols/, notimplementations/) matches the hexagonal vocabulary already in use, gives a clear intake rule for future capability-specific outbound protocols, and is honest about what these are — port contracts, not implementations. - No compatibility shim. Consistent with this ADR's "one contract
family, one canonical home per concept" principle (see "Alternatives
considered" above),
openframe/core/contracts/is deleted outright — no re-export module is left at the old path. - No class is renamed and no method signature changes. This is a
pure structural rename and merge; every name importable from
openframe.core.contractsbefore v3.1.0 is importable fromopenframe.core.portsafter it.BaseRepository,BaseProducer, andBaseConsumerremain importable from the top-levelopenframe.core.portspackage — theoutbound/sub-module is an internal reorganisation, not a public API change.
Consequences¶
- Dependency DAG simplifies.
portsis now the single apex module (afterexceptions → config) thatinboundandpluginsdepend on directly, rather than both depending on a separatecontractsmodule thatportsalso depended on. Withinports/, the six primitive modules form one linear chain and the threeports/outbound/modules each depend only onports/port— a clean two-tier structure, not a new cycle. ports/outbound/has a stated intake rule. Any future capability-specific outbound port protocol belongs inports/outbound/, re-exported fromports/__init__.py— not as a new top-levelports/module, and not defined inside the downstream package that first needs it. See capability-taxonomy.md.- Shipped as a minor version, not a major one. Version bumps
3.0.1→3.1.0inpyproject.toml— this is judged a structural reorganisation of the v3.0.0 contract layer rather than a further architectural change, so it does not warrant its own major version. Unlike the v3.0.0 break,openframe-adaptersand other ecosystem packages pinnedopenframe-core>=3.0,<4will auto-resolve to 3.1.0 on their next install. Because there is still no compatibility shim at the oldopenframe.core.contractspath, any package importing from it will fail immediately on upgrade — downstream packages still importingopenframe.core.contractsmust migrate toopenframe.core.portsbefore taking aopenframe-coreupgrade past 3.1.0, even though semver alone would not have warned them.