ADR-045: Stateful Stream Receiver Semantics¶
Status¶
Accepted Date: 2026-05-08 | Amended Date: 2026-08-07 | Amended Date: 2026-08-08 | Amended Date: 2026-09-04 | Amended Date: 2026-09-06 | Amended Date: 2026-10-12
Amended Date: 2026-05-09 — Consolidate to single async StreamablePort[T]; supersedes the dual-protocol decision below.
Amendment (2026-05-09): Consolidate Async-Only StreamablePort[T]¶
Context¶
ADR-045 (original, below) introduced AsyncStreamablePort[T] alongside the existing sync StreamablePort[T], with runtime detection (is_async flag) to call lifecycle methods via await or synchronously depending on which protocol the adapter implemented. This preserved backward compatibility for sync adapters.
Production experience with the dual-protocol design reveals it creates more friction than it resolves:
- Dual-registration surface — authors must choose the correct protocol key (
StreamablePort[T]vsAsyncStreamablePort[T]). A wrong choice registers silently and only fails at runtime with an ambiguous error. - Sync lifecycle is inherently unsafe — calling
open()synchronously on an async adapter drops the coroutine silently. Any adapter with real I/O (serial, BLE, USB) will have async lifecycle. A syncStreamablePortwithout async shims is either trivially stateless or subtly broken. - Disambiguation complexity in the runner —
find_stream_adapterhad to inspect both protocol origins, detect ambiguity, and carry anis_asyncbool through the call chain. The resulting_safe_call/push_async_callbacksplit added ~60 lines of branching code for a case that should not exist in well-structured code. - API surface cost —
AsyncStreamablePortin__all__and docs implies a permanent, maintained interface. Keeping it increases the conceptual load for new adopters without delivering value.
All first-party streaming adapters already use async lifecycle. No downstream application has shipped a sync StreamablePort implementation that cannot be trivially migrated (def open → async def open).
Decision¶
Remove AsyncStreamablePort[T] and the legacy sync StreamablePort[T]. Rename the async protocol to StreamablePort[T] — a single, async-only protocol with async def open/close/start_scan/stop_scan and sync register_callback. This is a breaking change scoped to the 0.4.x release boundary.
class StreamablePort[T_co](Protocol):
"""Async push-callback hardware adapter protocol.
Adapters register a callback (sync) and control the hardware lifecycle
via awaitable methods. The framework calls open → start_scan on entry
and stop_scan → close on exit. Handlers must not call lifecycle methods
on injected concrete adapter instances.
"""
async def open(self) -> None: ...
async def close(self) -> None: ...
async def start_scan(self) -> None: ...
async def stop_scan(self) -> None: ...
def register_callback(self, callback: Callable[[T_co], None]) -> None: ...
StreamablePort is not @runtime_checkable. Adapter resolution uses generic-alias origin matching (same as before), so isinstance checks are not needed and are explicitly disallowed to avoid false positives on incomplete implementations.
The stream runner (run_stream) always awaits lifecycle calls. The is_async detection branch and the _safe_call helper are removed. find_stream_adapter returns tuple[type, object] (no bool).
Migration¶
Sync StreamablePort[T] adapters require one-line changes per method:
# Before
def open(self) -> None:
self._serial.open()
# After
async def open(self) -> None:
self._serial.open()
The registration key is unchanged: app.adapter(StreamablePort[SensorReading], MyAdapter).
Decision Drivers (Amendment)¶
- All real hardware adapters use async lifecycle; the sync path served only backward compatibility, not production use cases
- Removing the sync variant eliminates the silent-coroutine-drop hazard unconditionally
- A single protocol simplifies
find_stream_adapter, removes_safe_call, and reduces runner code by ~60 lines AsyncStreamablePortin__all__and public docs carries ongoing maintenance cost; consolidation removes it before downstream adoption grows- Breaking changes of this scope belong at 0.4.x release boundaries; 0.4.x is the active development line
Consequences (Amendment)¶
Positive¶
- Single registration key:
StreamablePort[T]. No ambiguity between sync and async variants. - Runner code simplified: no
is_asyncdetection, no_safe_callbranching. - Public API reduced by one export (
AsyncStreamablePortremoved from__all__). - Resource-leak hazard from silent coroutine drops is eliminated at the type level.
Negative¶
- Breaking change: sync
StreamablePort[T]adapters must migrate toasync deflifecycle. Migration is mechanical but required. - Applications that imported
AsyncStreamablePortby name must update toStreamablePort.
Original Decision (2026-05-08)¶
Context¶
ADR-042 introduced StreamablePort[T] and Stream[T] as first-class framework primitives for push-callback hardware adapters (BLE, serial, HID). The design intentionally kept @app.stream narrower than @app.device: the stream runner owns the port lifecycle, injects Stream[T] into the handler, and the handler iterates via async for. No DeviceContext, DeviceStore, or concrete adapter injection was provided.
Production use of @app.stream (documented in the jeelink2mqtt Framework Enhancement Proposal) exposes three concrete capability gaps that prevent stateful streaming applications from using the framework as intended:
-
DI gap — stream handlers cannot inject
DeviceContext(needed for publishing telemetry, state, and availability) orDeviceStore(needed for restoring and persisting registry state between restarts). Thestart_stream_tasksprovider map incosalette._wiring._task_lifecycleincludes settings, adapters, state,ClockPort, andLogger, but notDeviceContextorDeviceStore. -
Lifecycle gap —
StreamablePort[T]defined five synchronous lifecycle methods (open,close,start_scan,stop_scan,register_callback). Hardware adapters that have already implemented async lifecycle (e.g.,async def close(self)) could not satisfy this protocol without adding a synchronous shim. The stream runner called these methods synchronously via_safe_call, which silently dropped the coroutine returned by an async implementation — causing resource leaks (e.g., serial ports not closed on shutdown). -
Testing gap —
AppHarness.inject_streambuilt a provider map limited to settings, state overrides,ClockPort, andLogger. Integration tests could not verify publishing, persistence, or concrete adapter interaction without stepping outside the framework's DI system.
The original decision introduced AsyncStreamablePort[T] with async lifecycle and runtime protocol detection to address gap (2) and preserve backward compatibility. Gaps (1) and (3) were closed by injecting DeviceContext/DeviceStore and improving inject_stream. The 2026-05-09 amendment supersedes the AsyncStreamablePort introduction by consolidating to a single async protocol.
Decision (Original)¶
(Superseded by amendment above for the dual-protocol design. DI injection for DeviceContext, DeviceStore, and concrete adapters, and AppHarness.inject_stream parity, remain in effect as originally decided.)
-
Lifecycle gap —
StreamablePort[T]defines five synchronous lifecycle methods (open,close,start_scan,stop_scan,register_callback). Hardware adapters that have already implemented async lifecycle (e.g.,async def close(self)) cannot satisfy this protocol without adding a synchronous shim. The stream runner calls these methods synchronously via_safe_call, which silently drops the coroutine returned by an async implementation — causing resource leaks (e.g., serial ports not closed on shutdown). -
Testing gap —
AppHarness.inject_streambuilds a provider map limited to settings, state overrides,ClockPort, andLogger. Integration tests cannot verify publishing, persistence, or concrete adapter interaction without stepping outside the framework's DI system.
The root cause is not that @app.stream was designed incorrectly — ADR-042's Queue + Event bridge is sound — but that the runtime and test harness were not extended to match the DI surface that @app.device provides. Without these extensions, authors of stateful streaming applications must either re-implement framework-owned concerns outside the framework (hand-rolling MQTT publish, persistence) or fall back to @app.device with explicit async context-manager and iterator boilerplate, defeating the purpose of the stream archetype.
Decision¶
Adopt targeted stateful stream capability parity for @app.stream. Extend the stream runtime (start_stream_tasks, run_stream) to inject stream-scoped DeviceContext and DeviceStore into the DI provider map when the handler declares them. Introduce AsyncStreamablePort[T] alongside the existing sync StreamablePort[T], with async lifecycle methods (open, close, start_scan, stop_scan); the stream runner detects which protocol the adapter implements and calls lifecycle methods accordingly, preserving backward compatibility for sync adapters. Allow concrete adapter injection for non-lifecycle operations (e.g., jeelink: JeeLinkPort for set_led) while the framework retains exclusive lifecycle ownership via the registered port protocol key — handlers must not call lifecycle methods on injected concrete adapters. Improve AppHarness.inject_stream to include DeviceContext, DeviceStore, and adapter providers, achieving production DI parity while still bypassing hardware lifecycle. Explicitly reject making @app.stream a full clone of @app.device: the async-generator / async-iterable handler model, yield-based reactor dispatch boundaries, and Stream[T]-mediated push-to-pull bridge are preserved unchanged.
@app.stream(summary="JeeLink LaCrosse serial receiver")
async def receiver(
ctx: DeviceContext,
stream: Stream[SensorReading],
store: DeviceStore,
jeelink: JeeLinkPort, # concrete adapter for non-lifecycle ops
settings: AppSettings,
) -> None:
"""Stateful push-callback receiver with DI parity.
Framework responsibilities (not visible here):
- AsyncStreamablePort[SensorReading] lifecycle: open/start_scan on entry,
stop_scan/close on exit
- DeviceContext and DeviceStore constructed and injected by the runtime
- DeviceStore saved on graceful shutdown
"""
# Restore persisted state before the first reading arrives
registry.restore_from(store)
async for reading in stream:
result = registry.record(reading)
if result.is_mapped:
await ctx.publish_state({"sensor": result.name, "value": reading.value})
store.mark_dirty()
yield # reactor dispatch boundary (ADR-043)
# jeelink.set_led(False) is legal — framework does NOT call lifecycle via jeelink
Decision Drivers¶
- Stateful streaming receivers need DeviceContext for MQTT publishing and DeviceStore for persistence — without these, @app.stream cannot be used for non-trivial IoT bridges
- Hardware adapters with async lifecycle must satisfy StreamablePort without synchronous shims; silent coroutine drops in _safe_call are a resource-leak hazard
- The stream archetype's async-generator model and Stream[T] push-to-pull bridge are correct and must be preserved; capability gaps should be closed, not redesigned
- AppHarness.inject_stream must provide production-equivalent DI so integration tests can verify publishing, persistence, and adapter behaviour without stepping outside the framework
- Lifecycle ownership must remain exclusively with the framework — handlers that inject concrete adapters must not be able to call lifecycle methods on them, to avoid double-open or unclosed resources
- Backward compatibility for existing sync StreamablePort[T] adapters must be preserved unconditionally
Considered Options¶
Option 1: App-side shim (no framework change)¶
Leave the framework unchanged. Application code adds thin wrapper functions that create DeviceContext, DeviceStore, and async lifecycle adapters outside the DI system. Shared boilerplate is published as a utility library or documented pattern for downstream apps.
- Advantages: Zero framework change: no regression risk, immediately available; Application authors retain full control over lifecycle and DI wiring; No API surface expansion in the framework
- Disadvantages: Every stateful streaming app re-invents the same lifecycle and DI boilerplate; Lifecycle ownership is ambiguous: the framework and the shim both touch the port; AppHarness.inject_stream cannot be improved without touching the framework, so testing gaps persist; AsyncStreamablePort adapters still require a synchronous shim to satisfy StreamablePort[T], keeping the resource-leak hazard
Option 2: Full device-equivalent @app.stream¶
Make @app.stream a superset of @app.device: expose the same DI surface, lifecycle management, reactor dispatch, topic management, health reporting, and restart strategy. Stream[T] becomes an optional bridge rather than the primary handler model.
- Advantages: Maximum capability parity: stream handlers can do everything device handlers can; Single mental model for all stateful handlers: same DI, same lifecycle, same testing idioms
- Disadvantages: Conflates two distinct archetypes: push-callback hardware bridges and polled device loops serve different hardware contracts; Breaks the Stream[T] async-generator boundary that ADR-042 established as the canonical pattern; @app.device already exists for full-featured stateful handlers; duplicating it adds maintenance surface; High implementation risk: lifecycle, health, restart, and topic semantics must all be replicated or generalised; Breaks backward compatibility for existing @app.stream handlers if the handler model changes from async generator to coroutine
Option 3: Targeted stateful stream parity (chosen)¶
Extend the @app.stream runtime with the minimum changes needed to close the three capability gaps: (1) add DeviceContext and DeviceStore to the DI provider map; (2) introduce AsyncStreamablePort[T] alongside sync StreamablePort[T] with runtime detection; (3) allow concrete adapter injection for non-lifecycle operations; (4) improve AppHarness.inject_stream DI parity. The async-generator handler model, Stream[T] bridge, and yield-based reactor boundaries are preserved unchanged.
- Advantages: Closes all three concrete capability gaps with targeted, auditable changes; Preserves Stream[T] async-generator model and yield-based reactor dispatch — no handler migration required; Backward compatible: existing sync StreamablePort[T] adapters require no changes; Lifecycle ownership remains exclusively with the framework — concrete adapter injection is scope-limited to non-lifecycle operations; AppHarness.inject_stream parity enables full integration test coverage without stepping outside the DI system
- Disadvantages: Two port protocols (StreamablePort[T] and AsyncStreamablePort[T]) increase DI surface complexity; Partial parity with @app.device may prompt questions about remaining gaps (health reporting, restart strategy) not addressed here; Concrete adapter injection requires clear documentation to prevent misuse of lifecycle methods
Option 4: Keep @app.stream narrow, use @app.device¶
Formalize @app.stream as a narrow, stateless-only archetype. Document that stateful streaming receivers belong in @app.device, which already has full DI support, and provide guidance on using async context managers and async for inside @app.device handlers for push-callback adapters.
- Advantages: Framework stays simple: no new protocol, no DI expansion for streams; Clear archetype boundary: @app.stream for stateless bridges, @app.device for stateful receivers; @app.device already provides DeviceContext, DeviceStore, and full DI — no new code required
- Disadvantages: Forces @app.device boilerplate (explicit async context manager, iterator wiring) on every push-callback receiver, eliminating the ergonomic benefit of @app.stream; StreamablePort[T] lifecycle management, the primary @app.stream value proposition, becomes opt-out rather than the default; Does not resolve the async lifecycle hazard (sync shim requirement) for adapters registered under either archetype; Contradicts the FEP's conclusion that @app.stream is the correct long-term archetype for push-callback receivers
Decision Matrix¶
| Criterion | App-side shim (no framework change) | Full device-equivalent @app.stream | Targeted stateful stream parity | Keep @app.stream narrow, use @app.device |
|---|---|---|---|---|
| Architecture fit (hexagonal, lifecycle separation) | 2 | 2 | 5 | 3 |
| Backward compatibility (existing handlers unmodified) | 5 | 1 | 5 | 5 |
| Stateful receiver support (DeviceContext, DeviceStore, adapter injection) | 2 | 5 | 4 | 2 |
| Testing ergonomics (inject_stream DI parity, no harness bypass) | 1 | 4 | 4 | 3 |
| Lifecycle clarity (framework owns lifecycle, no confusion) | 1 | 3 | 4 | 3 |
| Implementation risk (complexity, regression surface) | 5 | 1 | 4 | 5 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- @app.stream handlers can now publish telemetry, state, and availability to MQTT via injected DeviceContext without any boilerplate outside the framework
- DeviceStore injection enables stateful stream receivers to restore and persist registry state across restarts, achieving functional parity with @app.device for the common IoT bridge pattern
- AsyncStreamablePort[T] eliminates the resource-leak hazard where synchronous _safe_call silently dropped coroutines returned by async lifecycle methods
- Backward compatibility for all existing sync StreamablePort[T] adapters is unconditionally preserved — no migration required
- AppHarness.inject_stream DI parity enables complete integration test coverage (publishing, persistence, adapter interaction) within the framework's test infrastructure
- The Stream[T] async-generator model and yield-based reactor dispatch boundaries (ADR-043) are preserved, keeping the push-callback bridge pattern as the canonical @app.stream idiom
Negative¶
- Two registered port protocol keys (StreamablePort[T] and AsyncStreamablePort[T]) increase adapter registration surface; documentation must clearly state which key to use for each adapter type
- Concrete adapter injection for non-lifecycle operations requires explicit documentation to prevent misuse — handlers must not call lifecycle methods on the injected adapter instance, which the framework cannot statically enforce
- Partial parity with @app.device (health reporting and restart strategy remain @app.device-only) may prompt future FEP requests to close remaining gaps
Amendment (2026-08-07) — Additive¶
Rationale: ADR-045 gave @app.stream a DeviceContext and therefore the ability to publish to a static retained {prefix}/{stream}/state topic, but it did not revisit the decorator's contract metadata. The metadata set for @app.stream was fixed on 2026-04-27 (commit 0eaa52e), when streams had no DeviceContext and could not publish at all. The result is a capability without a contract: @app.stream is the only publishing archetype that accepts no state_model, so a stream can ship a malformed state payload to a retained topic and nothing in the framework notices. Stream handlers are async generators yielding None, so get_return_annotation can never supply a fallback type — an explicit state_model is the only possible contract source. The same gap exists in mirror image on @app.device, which has accepted state_model since 0.5.6 but treated it as introspection metadata only, even though device handlers publish through the same DeviceContext.publish_state. This amendment closes both, and records the applicability judgement for the generated artifacts that epic cos-bnq left implicit.
Additional Sub-Decision: state_model on @app.stream and Runtime Validation in publish_state¶
@app.stream (and @router.stream, App.add_stream) accepts state_model: type | None = None. When declared, the model is threaded from _StreamRegistration through build_stream_contexts onto the stream-scoped DeviceContext, and every ctx.publish_state() call from that handler is validated and normalised against it via Pydantic TypeAdapter, raising ReturnValidationError on a mismatch. This is the same engine and the same exception type telemetry and commands already use (ADR-046), reached by a different route.
The route has to be different. Telemetry and commands validate a handler return value through normalize_handler_return, which takes an EAFP dump_python fast path — free for values that are already model instances. Stream handlers return nothing; they hand publish_state a dict[str, object] they assembled themselves. dump_python on a plain dict against a BaseModel adapter does not validate: Pydantic emits a serializer warning and passes the dict straight through. The published-state path therefore always validate_pythons first, then dumps. A new validate_state_payload() helper in _runners/_contracts.py encapsulates this, and is deliberately separate from normalize_return so the two orderings cannot be conflated.
Validation errors name the offending field paths, the model, and the handler, e.g. Published state does not match state_model 'Reading' in handler 'app.receiver': value: type=missing. As with PayloadValidationError, the message is built only from framework-owned data (field location codes and Pydantic error-type codes) and never echoes the rejected payload (OWASP A03).
state_model=None — the default and the behaviour of every handler written before this amendment — skips the path entirely. No TypeAdapter is built, no branch cost beyond a single is not None check per publish.
Scope is the static state topic only. ctx.publish(channel, payload) is an explicit escape hatch and ctx.sub_entity(...) channels carry their own shapes; neither is validated by the device-level state_model.
Additional Sub-Decision: @app.device's state_model Becomes Load-Bearing Too (Breaking)¶
The same wiring is applied to @app.device: build_contexts installs state_model on the DeviceContext for _DeviceRegistration entries. Declaring state_model on @app.device therefore now validates published state, where before 0.6.0 it only typed the AsyncAPI state channel emitted by cosalette schema init.
Scoping this to streams alone was considered and rejected. It would have left an identically inert state_model on @app.device sitting immediately beside the one being fixed — the exact defect this work exists to close — and would have forced users to learn a per-decorator matrix instead of one rule: if you declare state_model, published state is validated.
Telemetry and command registrations deliberately contribute nothing to the context. Their state_model already validates the handler return value before publish_state is reached; re-validating the resulting JSON dict would check the same contract twice. The split is safe because device names collide with every other registration kind (colliding_names), so a device never shares a DeviceContext with telemetry or a command — the model installed on a context is always unambiguous.
This is a breaking change for device handlers that declare state_model and publish non-conforming payloads. Those handlers are shipping malformed state to a retained topic today; the break surfaces an existing defect rather than introducing one. Migration is one of two one-line choices: fix the payload to match the model, or drop state_model= to return to unvalidated publishing. Handlers that never declared state_model are unaffected.
AppHarness._make_stream_ctx threads state_model as well, preserving the production-DI parity for inject_stream that this ADR's original decision established — a contract violation fails in tests exactly as it would at runtime.
Additional Sub-Decision: Applicability Judgement: Introspection Yes, AsyncAPI No¶
Epic cos-bnq listed "streams and reactors where applicable" in scope and shipped without them, leaving no record of the applicability judgement. summary/behavior/effects on @app.stream and summary/behavior on @app.periodic were consequently stored on the frozen registration dataclass and read by nothing — not app.asyncapi(), not the manifest, not the MCP inspect tool, not schema init. The @app.stream docstring's "Informational only" read as "appears in the manifest" rather than "discarded". The judgement is recorded here.
Introspection: yes. build_registry_snapshot gains streams and periodic sections, with matching Streams and Periodic tables in format_registry_table. Stream entries carry summary, state_model, behavior, effects, maxsize and backpressure; periodic entries carry summary, behavior and interval. The metadata now reaches the cosalette_inspect_app MCP tool and the public build_registry_snapshot / format_registry_table API. It does not reach cosalette manifest, which emits AsyncAPI (see below). The decorator docstrings are corrected to say so.
AsyncAPI: no, and not because of "dynamic per-sensor topics". That claim in _schema/_cli.py was false — a stream publishes to the same static {prefix}/{name}/state topic a device does — and it cited ADR-033, which says nothing on the subject. The real reasons: x-cosalette-archetype is a closed enum {telemetry, command, device} validated by the schema loader, so a stream channel has no representable archetype and older cosalette versions would reject a document containing one; and AsyncAPI is not documentation here but the artifact schema check gates against and schema ha-discovery derives Home Assistant entities from, so emitting stream channels would silently add HA entities on the next regeneration. Adding a fourth archetype is a defensible future change — state_model is precisely the static signal that would make it sound — but it is a cross-version schema-compatibility decision that needs its own ADR, not a side effect of this one. Until then, schema check continues to subtract stream names from its EXTRA comparison, now with an accurate comment.
Periodic in AsyncAPI: categorically no. Periodic tasks have no MQTT presence by design (ADR-041), which is also why they carry no state_model, payload_model, or effects.
Additional Positive Consequences¶
- @app.stream publishes under a declarable, enforced contract: a malformed state payload fails loudly at the publish call instead of reaching a retained MQTT topic and propagating to every subscriber
- One rule across all publishing archetypes — declare state_model and published state is validated — replacing a per-decorator matrix of which contract fields are load-bearing
- Validation errors name the offending fields, the model, and the handler, while never echoing the rejected payload (OWASP A03), matching the diagnostic quality of the inbound PayloadValidationError path
- Contract metadata on @app.stream and @app.periodic reaches a real artifact (the registry snapshot, surfaced by cosalette_inspect_app) instead of being stored on the registration and discarded
- The false 'dynamic per-sensor topics (ADR-033)' rationale for excluding streams from AsyncAPI is replaced by the actual reason, and the applicability judgement epic cos-bnq left implicit is now recorded
- AppHarness.inject_stream validates published state exactly as production does, so contract violations surface in tests rather than in the field
Additional Negative Consequences¶
- Breaking change: @app.device handlers that declare state_model and publish non-conforming payloads now raise ReturnValidationError where they previously published silently — migration is to fix the payload or drop state_model=
- Published state is normalised, not merely checked: a declared model applies field aliases, custom serialisers, and type coercion (an int 3 for a float field publishes as 3.0), so the wire payload can differ from the dict the handler passed in
- Two validation entry points now exist for published state — normalize_return for handler return values and validate_state_payload for publish_state payloads — and their differing dump/validate ordering is a subtlety future maintainers must respect
- state_model validation is confined to the static state topic; ctx.publish() and sub-entity channels remain unvalidated, so the contract does not cover a device's full publish surface
- Streams remain absent from AsyncAPI, so a stream's declared state_model does not yet reach schema check, ha-discovery, or openHAB generation — a follow-up ADR is required to close that asymmetry
Amendment (2026-08-08) — Minor¶
Editorial note (2026-08-08)
The 'AsyncAPI: no' sub-decision in the 'Applicability Judgement: Introspection Yes, AsyncAPI No' section is now partially superseded for streams. ADR-054 (2026-08-08) accepted a fourth x-cosalette-archetype value (stream) and enabled stream AsyncAPI emission as a send/publish state channel at {prefix}/{name}/state. Periodic tasks remain categorically excluded (no MQTT presence, ADR-041). The stream half of this sub-decision no longer applies; see ADR-054 for the current stream-AsyncAPI policy, migration path, and HA-discovery exclusion guard.
Amendment (2026-09-04) — Minor¶
Editorial note (2026-09-04)
The premise in the sub-decision "@app.device's state_model Becomes Load-Bearing Too (Breaking)" that "Telemetry and command registrations deliberately contribute nothing to the context. Their state_model already validates the handler return value before publish_state is reached" is incorrect, and is corrected by ADR-068 (2026-09-04). On @app.telemetry / @app.command a return annotation displaced the explicit state_model= in normalize_handler_return (_contracts.py:444, annotation = get_return_annotation(func) or state_model), and the EAFP dump_python fast path republished a non-conforming plain dict unchanged (the Pydantic serializer warning was swallowed), so state_model= was a runtime no-op in the common case — the exact per-archetype gap this amendment set out to close, still open on two of four archetypes.
ADR-068 makes explicit state_model= outrank the return annotation, makes the fast path fail closed (dump_python(..., warnings="error") -> caught -> validate_python -> ReturnValidationError published to {prefix}/{name}/error, state publish suppressed), and adopts exclude_none=True on both normalize_return and validate_state_payload so the one rule has no archetype-dependent output shape. The latter changes the @app.device / @app.stream wire payload for any state_model with optional fields currently published as explicit null (the key becomes absent). Shipped as a direct breaking change in 0.9.0. The "one rule across all publishing archetypes" consequence recorded above now holds in implementation, not just in intent.
Amendment (2026-09-06) — Additive¶
Rationale: This ADR's Decision designates AppHarness.inject_stream as the harness path for @app.stream, on the premise that it reaches production DI parity "while still bypassing hardware lifecycle". That framing was complete only because there was no other path: AppHarness.run() emptied app._streams unconditionally (a suppression introduced by commit 0eaa52e with no recorded rationale), and inject_stream resolves its handler from that very list — so calling it during an in-flight run() raises ValueError: No stream handler named '<name>' found. ADR-064/ADR-065 then introduced a shape the seam cannot express: a @app.stream handler arming a concurrently running @app.device/@app.telemetry entity through the app's EntityNotifier, with both halves publishing. Downstream (jeelink2mqtt) that costs ~40 lines of hand-built cosalette.DeviceContext reconstructing what run() already wired, in every app that adopts the pattern. A run_streams= opt-in on AppHarness, mirroring run_periodic= (ADR-041), adds a second harness path that runs the real stream lifecycle. Two paths need a stated rule for which to reach for, so the single-seam framing is recorded as incomplete on ship rather than left to be inferred.
Additional Sub-Decision: run_streams= Runs the Real Stream Lifecycle Under AppHarness.run()¶
AppHarness gains a run_streams: bool = False field and the matching create(..., run_streams=False) keyword, placed beside run_periodic= and behaving the same way: run() gates the suppression (if not self.run_streams: self.app._streams = []) instead of applying it unconditionally, and the existing backup/restore around the call already covers both paths, on the happy path and on an exception.
With run_streams=True the framework — not the test — opens the registered StreamablePort[T], calls register_callback/start_scan, builds the stream-scoped DeviceContext through build_stream_contexts, and starts the handler as one of the app's tasks, concurrently with its devices and telemetry, under the harness's single MockMqttClient and single clock. A stream handler can therefore arm a triggerable="local" device through the same EntityNotifier instance production binds, and a test asserts on one recorder and one ManualClock — including that the armed device's publish lands within the same virtual tick, with clock.now() unmoved.
No production module changes: start_stream_tasks, run_stream, build_stream_contexts and the _wiring package are untouched. The default stays False, so every test written against the old unconditional suppression keeps its behaviour, and the stream's source still needs a double — exactly as a periodic task's inputs do under run_periodic=True.
Additional Sub-Decision: Which Seam to Reach For — inject_stream() vs run_streams=True¶
The original Decision's inject_stream designation stands, narrowed to what it is good at. The rule:
Reach for inject_stream() when the handler's own logic is under test: feed it items, assert on what it computed, published, or persisted. It needs no port double and runs no lifecycle, items are delivered exactly and in order, and the DI parity this ADR established (DeviceContext, DeviceStore, concrete adapters, state_model validation) applies. This remains the default seam and covers most stream tests.
Reach for run_streams=True when the integration is under test: the handler's effect on another concurrently running entity — the ADR-064/ADR-065 stream-arms-device shape — or on anything that only exists once the real lifecycle runs (port open/scan ordering, the framework-bound EntityNotifier, publishes interleaved with a device's on one recorder).
The two are alternatives, not layers, within one run(). inject_stream() called during an in-flight run_streams=True run resolves a handler the lifecycle is already driving and would run a second copy of it against a hand-made Stream; feed the port double instead. Under the False default inject_stream() during run() still raises ValueError: No stream handler named '<name>' found, because the registration list is empty for the duration.
Additional Sub-Decision: A Missing StreamablePort Adapter Fails Fast Under run_streams=True¶
run_stream raises RuntimeError when no StreamablePort[T] matches the handler's Stream[T] parameter, but that error is raised inside a stream task: _wiring/_tasks.py parks on the shutdown event and _task_lifecycle only logs the failure during teardown. Left alone, a test that sets run_streams=True and forgets app.adapter(StreamablePort[T], ...) boots green with no stream running, then hangs until wait_for_publish_count gives up — with hint text about a publish gated behind a scheduled sleep, pointing the reader at the clock rather than at the missing adapter.
So when run_streams=True, run() validates the registrations eagerly, before delegating to _run_async: each registered stream is resolved against the runner's own find_stream_adapter rule over the app's pre-run adapter registry, and a miss raises that same RuntimeError — naming the stream and the StreamablePort[<Item>] to register — from run() itself, before anything is published.
A stream whose enabled= is a callable spec is skipped: that spec is resolved during bootstrap, so a stream that may yet be dropped is not pre-judged. Such a stream, if bootstrap does keep it, still fails the old way. The check is confined to the harness; production behaviour on a missing port is unchanged.
Additional Positive Consequences¶
- The stream-arms-device shape ADR-064/ADR-065 introduced is expressible through the harness's public surface — one MockMqttClient, one clock, one framework-bound EntityNotifier — instead of a hand-built cosalette.DeviceContext that reconstructs what run() already wired
- This ADR's single-seam framing is replaced by two named seams with a stated rule, so a test author choosing between them reads a decision rather than inferring one from the suppression comment in run()
- run_periodic= and run_streams= are now symmetric (the ADR-041 precedent), leaving the harness one opt-in shape for background handler classes rather than one opt-in beside one unconditional suppression
- A forgotten StreamablePort double raises from run() naming the stream and the port type, instead of producing a silently green boot that hangs until wait_for_publish_count gives up with a hint pointing at the clock
- The capability is additive by construction: the False default preserves every existing test, and no production module (start_stream_tasks, run_stream, build_stream_contexts, _wiring) changes
Additional Negative Consequences¶
- There are now two harness seams for streams and the choice is the test author's; picking wrong is not a hard error, and inject_stream() during an in-flight run_streams=True run silently runs a second copy of the handler against a hand-made Stream
- run_streams=True costs more setup than inject_stream(): every registered stream needs a StreamablePort double, and item delivery goes through the port's callback rather than being handed to the handler directly — which is why the default stays False
- The eager port check deliberately skips streams with a callable enabled= spec, so that case keeps the old failure mode (logged at teardown, test hangs) — the fail-fast guarantee is not total
- The check reads app._streams and the pre-run adapter registry from the harness, so it is coupled to registrations being complete before run() is called; a future change that resolves adapters later would need it revisited
Amendment (2026-10-12) — Minor¶
Editorial note (2026-10-12)
Implementation note (2026-10-12): Pydantic string location components in validation summaries are redacted as [field] because they can be caller-controlled mapping keys or extra-field names. Integer sequence indexes and error type codes remain available; exact string field paths are intentionally omitted to avoid disclosing input-controlled values.