ADR-020: Deferred Interval Resolution¶
Status¶
Accepted Date: 2026-03-04 | Amended Date: 2026-03-06
Context¶
Cosalette's add_telemetry() and @app.telemetry() require an interval: float
parameter that controls the polling frequency. The interval is stored in a frozen
_TelemetryRegistration dataclass and used verbatim by TelemetryRunner.run_telemetry() and
TelemetryRunner.run_telemetry_group() (in _telemetry_runner.py) to control ctx.sleep() durations.
Applications naturally want to derive polling intervals from configuration:
app = App(name="myapp", settings_class=MySettings)
for group in GROUPS:
app.add_telemetry(
name=group,
func=make_handler(group),
interval=app.settings.get_interval(group), # reads from settings
)
This pattern has two problems:
-
--help/--versioncrash. When required environment variables are absent,App.__init__catches theValidationErrorand stores_settings = None(intentionally lenient). Butapp.settingsthen raisesRuntimeError, crashing the process before the CLI can handle--help/--version— flags that don't need valid settings at all. -
Stale intervals. The CLI callback (
build_cli) re-creates settings with--env-filesupport and passes them to_run_async(). But the intervals were already baked into frozen_TelemetryRegistrationobjects during registration. The CLI-rebuilt settings never update the scheduler intervals — only settings accessed via DI inside handler functions reflect the--env-filevalues.
Both issues were discovered during the vito2mqtt early-adopter project (workspace-4zb),
where 7 telemetry groups derived their intervals from a Vito2MqttSettings subclass.
Decision¶
Introduce IntervalSpec = float | Callable[[Settings], float] as the accepted type
for the interval parameter. When a callable is provided, it is resolved to a concrete
float in _run_async() — via _wiring.resolve_intervals() — after settings are
resolved, before any device tasks start.
# Application usage — callable interval (deferred)
app.add_telemetry(
name="outdoor",
func=handler,
interval=lambda s: s.polling_outdoor, # resolved at runtime
)
# Still works — concrete float (immediate)
app.add_telemetry(name="sensor", func=handler, interval=30.0)
Resolution happens once, via _wiring.resolve_intervals(telemetry, settings) called
at the top of _run_async(). After resolution, all _TelemetryRegistration.interval values
are concrete floats — downstream code (TelemetryRunner.run_telemetry,
TelemetryRunner.run_telemetry_group, _init_group_handlers) never sees callables.
Validation¶
- Float intervals: Validated eagerly at registration time (
<= 0raisesValueError). This is unchanged. - Callable intervals: Validation is deferred to
_wiring.resolve_intervals(). The callable is invoked with the resolvedSettingsinstance, and the returned value is checked for<= 0. This is a necessary tradeoff — the callable's return value isn't known until settings exist.
Type narrowing¶
Since _TelemetryRegistration.interval is typed as IntervalSpec (a union), downstream
code uses cast(float, reg.interval) for type narrowing. This is safe because
_wiring.resolve_intervals() guarantees all callables are resolved before downstream code
runs. cast() was chosen over assert isinstance() because:
- It's zero-cost at runtime (no per-sleep-cycle overhead)
- The invariant is established by a dedicated resolution step, not runtime checking
assert isinstance(x, float)would rejectintintervals (e.g.interval=10)
Decision Drivers¶
- First early-adopter failure. The vito2mqtt project is the first real application
built on cosalette. A crash on
--helpis a critical UX failure that blocks adoption. - Backward compatibility. The framework is at 0.1.x with published adopters.
Changing the registration pattern (e.g. an
on_configurehook) would be too disruptive. - Resolve-at-boundary principle. Callable intervals are resolved once at the
_run_asyncboundary (delegated to_wiring.resolve_intervals()), converting the union to a concrete type. Downstream code operates onfloatonly — no union handling needed in hot paths.
Considered Options¶
Option A: IntervalSpec — callable or float (Chosen)¶
Widen the interval parameter type to float | Callable[[Settings], float]. Resolve
callables in _run_async() after settings are available.
- Advantages: Backward-compatible. Module-level registration pattern preserved. Solves both the crash and the stale-interval problem. Minimal API surface change.
- Disadvantages: Validation deferred for callables. Union type adds
cast()in downstream code.
Option B: on_configure lifecycle hook¶
Add an @app.on_configure hook that receives resolved settings. Applications register
telemetry inside the hook instead of at module level.
- Advantages: Clean phase separation.
intervalstaysfloat. - Disadvantages: Breaking change to the registration pattern. Modules no longer export a fully-configured app. Harder to test. Would require rethinking the framework's ergonomic model.
Option C: interval_key string mapping¶
Add an interval_key: str parameter that names a settings field. The framework
reads getattr(settings, key) at runtime to resolve the interval.
- Advantages: Registration works without settings. Simple.
- Disadvantages: Couples framework to settings field naming. Fragile (typos fail at runtime). Inflexible — can't express computed intervals.
Decision Matrix¶
| Criterion | A: IntervalSpec | B: on_configure | C: interval_key |
|---|---|---|---|
| Backward compatibility | 5 | 2 | 4 |
| Registration ergonomics | 5 | 3 | 4 |
| Type safety | 4 | 5 | 2 |
| Implementation complexity | 4 | 3 | 3 |
| Solves stale-interval gap | 5 | 5 | 4 |
| Total | 23 | 18 | 17 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- Applications can derive telemetry intervals from settings without crashing on
--help/--version - CLI-rebuilt settings (including
--env-file) now influence scheduler intervals, closing the stale-interval gap - Fully backward-compatible — existing
interval=5.0code works identically - Module-level registration pattern preserved — no architectural change required
IntervalSpectype alias is exported as part of the public API, making the pattern discoverable
Negative¶
- Callable intervals defer validation to
_run_async()— errors surface later than they would for float intervals (mitigated:_run_asyncruns early in the lifecycle, before any device tasks start) - Three downstream
cast(float, ...)sites add a small maintenance burden (mitigated: the resolution step is tested and the pattern is documented) - Lambda typing requires explicit annotation for type-checkers to know the settings
subclass:
lambda s: s.my_fieldtreatssasSettings, notMySettings(mitigated: the baseSettingstype is sufficient for most use cases)
Editorial note (2026-03-06)
Since this ADR was written, _app.py was decomposed into focused modules.
Interval resolution now lives in _wiring.resolve_intervals() and the
telemetry polling loop is implemented by TelemetryRunner in
_telemetry_runner.py. The decision and its semantics are unchanged.
Editorial note (2026-03-07)
The cast(float, reg.interval) calls in _telemetry_runner.py were replaced
with a runtime-validating helper _resolved_interval() that narrows the type
while asserting the invariant — raising TypeError if a callable interval
slips through unresolved.
2026-03-04