ADR-038: Deferred enabled= for Decorator Registrations¶
Status¶
Accepted Date: 2026-04-20
Context¶
The @app.telemetry(), @app.device(), and @app.command() decorators accept an enabled= parameter that controls whether a device is registered. Before this change the parameter only accepted a literal bool. When the enabled/disabled decision depends on runtime settings — for example enabled=settings.enable_debug_device — adopters cannot use the decorator form and must fall back to imperative add_telemetry() calls inside @app.on_configure.
This breaks the "declarative main.py" goal established in ADR-001: if even one device has a settings-dependent enabled=, all devices must move to on_configure for consistency, otherwise the module mixes module-level decorator registrations with imperative on_configure registrations.
The interval= parameter already supports Callable[[Settings], float] for deferred resolution via resolve_intervals() in _wiring.py (ADR-020). The enabled= parameter had no equivalent — it was consumed at decoration time and not stored in the registration dataclass.
The gas2mqtt early-adopter project surfaced this gap: a magnetometer with enabled=settings.enable_debug_device forced the entire main.py into on_configure-based registration for consistency.
Decision¶
Extend enabled= on decorator forms to accept EnabledSpec = bool | Callable[..., bool]. When a callable is provided, the decision is deferred to the bootstrap phase — after settings resolution, alongside resolve_intervals() — via a new resolve_enabled() function in _wiring.py. The callable receives the resolved Settings instance (or a per-device config value in multi-device mode). Entries disabled by the callable are removed from the registry before MQTT wiring begins; entries enabled by the callable have deferred constraints (persist= requiring a store, triggerable/group exclusion) validated at that point. The imperative add_telemetry(), add_device(), and add_command() methods retain enabled: bool only — they run inside on_configure where settings are already available.
@app.telemetry(
"magnetometer",
interval=lambda s: s.poll_interval,
enabled=lambda s: s.enable_debug_device, # deferred — resolved at bootstrap
)
async def magnetometer(mag: MagnetometerPort) -> dict[str, object]:
reading = mag.read()
return {"bx": reading.bx, "by": reading.by, "bz": reading.bz}
Decision Drivers¶
- Preserve the fully-declarative main.py goal from ADR-001 — every device visible at module level
- Follow the established interval= callable precedent (ADR-020) for consistency and minimal API surface
- No on_configure boilerplate required for apps whose only settings dependency is enabled/disabled
- Backward compatible — literal bool still works identically, no migration required
- Deferred validation should only fire for devices that are actually enabled
Considered Options¶
Option 1: Callable EnabledSpec (Option A) (chosen)¶
Accept bool | Callable[..., bool] for enabled= on all three decorator forms. Store the callable in a new enabled_spec field on the registration dataclass. Resolve callables in _wiring.resolve_enabled() called during bootstrap after resolve_intervals(), mutating the registration lists in place (removing disabled entries). Defer validation of persist=/store and triggerable constraints until resolve_enabled() for callable-enabled registrations.
- Advantages: Exact parallel to the existing callable interval= pattern — minimal new concepts; Zero boilerplate: apps that only need settings-dependent enabled can stay fully declarative; Clean separation: resolution in _wiring.py keeps App class lean; Backward compatible: literal bool path unchanged
- Disadvantages: Registration exists in memory briefly before being pruned — minor overhead; Decorator validation flow is now conditional on whether enabled is callable or literal; Deferred validation produces bootstrap-time errors rather than decoration-time errors for callable-enabled devices
Option 2: Setter / late-bind (Option B)¶
Keep enabled= as bool at decoration time. Add a separate app.disable(name) method or a @app.on_configure hook that can remove registrations. Adopters would configure conditional registration via on_configure but through a cleaner API.
- Advantages: No changes to registration dataclasses; Decoration-time validation is fully eager
- Disadvantages: Does not solve the core problem: main.py still splits registrations across module level and on_configure; Introduces a mutable app registry pattern that conflicts with the immutable-registration design; Still requires on_configure boilerplate
Option 3: DI-injectable enabled flag (Option C)¶
Introduce a special EnabledFlag injectable type that the framework resolves before starting device tasks. Handlers would declare enabled: EnabledFlag as a parameter; the framework would skip starting the task if resolved to False.
- Advantages: Lazy — decisions made at task-start time, not bootstrap; Could handle dynamic enable/disable at runtime
- Disadvantages: Significantly more complex implementation; Introduces a new injectable type with special semantics; Does not integrate with the existing registration/wiring model; Runtime dynamic enable/disable is out of scope and would require task lifecycle management
Decision Matrix¶
| Criterion | Callable EnabledSpec (Option A) | Setter / late-bind (Option B) | DI-injectable enabled flag (Option C) |
|---|---|---|---|
| Declarative main.py preserved | 5 | 2 | 3 |
| Consistency with existing patterns | 5 | 2 | 1 |
| Implementation simplicity | 4 | 3 | 1 |
| Backward compatibility | 5 | 4 | 5 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
- Fully declarative main.py — every device visible at module level with enabled=lambda alongside interval=lambda
- No on_configure needed for apps whose only settings dependency is enabled/disabled
- EnabledSpec mirrors IntervalSpec — one new concept, consistent mental model
- Deferred validation (persist=/store, triggerable/group) fires only for actually-enabled devices, giving accurate error messages
- No breaking changes — all existing code continues to work
Negative¶
- Decorator validation is now split: structural checks (interval/schedule, group non-empty, retry) are always eager; constraint checks (persist/store, triggerable/group exclusion) are deferred when enabled is callable
- A device with callable enabled= briefly occupies a registration slot before bootstrap prunes it — negligible in practice
- The imperative add_*() methods intentionally do not support callable enabled= to keep their contract simple; this asymmetry must be documented
2026-04-20