Registry Introspection¶
Cosalette's introspection system lets you inspect all registered devices, telemetry, commands, and adapters at any point after registration — before the app even starts running.
Why Introspect?¶
A cosalette app assembles its registrations declaratively via decorators:
@app.telemetry("temp", interval=30, publish=Every(seconds=60) | OnChange())
async def temp() -> dict[str, object]:
return {"celsius": read_sensor()}
But once an app has dozens of registrations across multiple files, it becomes hard to answer simple questions:
- What devices are registered?
- What interval does
tempuse? Is it deferred? - Which telemetry uses a
SaveOnPublishpolicy? - What adapters are wired up?
build_registry_snapshot() answers all of these programmatically.
The Snapshot¶
build_registry_snapshot(app) returns a plain Python dict that is
fully JSON-serializable — no custom encoders needed:
import cosalette
from cosalette import build_registry_snapshot
app = cosalette.App(name="mybridge", version="1.0.0")
# ... register devices, telemetry, commands, adapters ...
snapshot = build_registry_snapshot(app)
The returned dict has this structure:
{
"app": {
"name": "mybridge",
"version": "1.0.0",
"description": "IoT-to-MQTT bridge",
},
"devices": [...],
"telemetry": [...],
"commands": [...],
"streams": [...],
"periodic": [...],
"adapters": [...],
}
Telemetry Entries¶
Each telemetry entry captures the full configuration:
{
"name": "temp",
"type": "telemetry",
"func": "sensors.temp",
"interval": 30.0, # (1)!
"strategy": "AnyStrategy(Every(seconds=60.0), OnChange())", # (2)!
"persist": "SaveOnPublish()", # (3)!
"group": "sensors", # (4)!
"is_root": False,
"has_init": False,
"dependencies": [["store", "DeviceStore"]], # (5)!
"triggerable": True, # (6)!
"trigger_source": "local", # (7)!
"min_interval": 2.5, # (8)!
}
- Concrete float, or
"<deferred>"if the interval is a settings-derived callable - Strategy
repr()— composites are shown recursively - Persist policy
repr(), ornullif not set - Coalescing group name, or
null - Injected parameters as
[param_name, type_name]pairs Truewhen the entity accepts a wake (triggerable=), elseFalse- Trigger source string (
"local") ornullwhen not triggerable (ADR-065) - Storm-throttle floor in seconds, or
nullwhen unthrottled (ADR-066)
The triggerable / trigger_source / min_interval fields appear on both
telemetry and device entries. format_registry_table() renders
trigger_source and min_interval as Trigger and Min interval
columns (em-dash when absent) — surfaced from the terminal via
cosalette manifest --registry --table (see
CLI Reference).
Deferred Intervals¶
Intervals can be a concrete float or a callable that resolves from settings at runtime (see ADR-020):
# Concrete — shows as 30.0
@app.telemetry("temp", interval=30.0)
# Deferred — shows as "<deferred>"
@app.telemetry("temp", interval=lambda s: s.sensor_interval)
Before the app runs, deferred intervals cannot be resolved because settings
haven't been validated yet. The snapshot shows "<deferred>" as a clear
indicator.
Device and Command Entries¶
# Device entry
{
"name": "motor",
"type": "device",
"func": "devices.motor",
"is_root": False,
"has_init": True,
"triggerable": True,
"trigger_source": "local",
"min_interval": None,
"dependencies": [["ctx", "DeviceContext"]],
}
# Command entry
{
"name": "valve",
"type": "command",
"func": "handlers.valve",
"mqtt_params": ["payload", "topic"],
"is_root": False,
"has_init": False,
"dependencies": [],
}
Stream and Periodic Entries¶
# Stream entry
{
"name": "receiver",
"type": "stream",
"func": "streams.receiver",
"enabled": True,
"is_root": False,
"maxsize": 0,
"backpressure": "drop_newest",
"summary": "Read sensor frames from the serial port",
"state_model": "FrameState", # (1)!
"behavior": ["decodes LaCrosse frames"],
"effects": ["publishes per-sensor state"],
"dependencies": [["ctx", "DeviceContext"]],
}
# Periodic entry
{
"name": "cache-refresh",
"type": "periodic",
"func": "tasks.refresh_cache",
"interval": 60.0,
"enabled": True,
"has_init": False,
"summary": "Refresh the upstream cache",
"behavior": ["evicts stale entries"], # (2)!
"dependencies": [["cache", "CachePort"]],
}
- Class name of the declared
state_model, ornull. On streams this is runtime load-bearing — it validates everyctx.publish_state()payload (see Validated Published State). - Periodic tasks carry no
state_model,payload_model, oreffects: they have no MQTT presence by design (ADR-041).
Periodic tasks never appear in the generated AsyncAPI document — they have no MQTT
presence by design (ADR-041), so the
snapshot is the only place their contract metadata surfaces. Streams now emit an
AsyncAPI state channel (x-cosalette-archetype: stream) as of
ADR-054, reversing
ADR-045's original exclusion;
the registry snapshot additionally carries stream-only fields that AsyncAPI does not
(maxsize, backpressure, dependencies).
The AsyncAPI document also describes framework-owned topics that have no registry
entry of their own. Since contract version "2"
(ADR-086),
every device, telemetry, command and named stream gets an availability channel
({name}Availability at {prefix}/{name}/availability, or availability at
{prefix}/availability for the root entity). These channels carry
x-cosalette-framework: "availability" instead of an archetype. A root stream is
heartbeat-only and gets none. The registry snapshot does not list them, and the
cosalette manifest table leaves them out so each entity appears once.
Adapter Entries¶
Adapter impl and dry_run fields show:
- Class name for type-based registration
- Import string for lazy registration (e.g.,
"mypackage.adapters:MyAdapter") - Qualified name for callable factories
Use Cases¶
| Use case | How |
|---|---|
| Agent consumption | The cosalette_inspect_app MCP tool returns the snapshot as JSON via format_registry_json() (see _mcp/_introspect_tools.py) |
| Programmatic/scripted use | Call build_registry_snapshot(app) directly, then format_registry_table()/format_registry_json() (see Formatting below) |
| Test assertions | Verify registration correctness in integration tests |
Which CLI surface renders the registry snapshot
The app-runtime --show-devices and --show-devices-json flags (see
CLI Reference) render the
AsyncAPI document (app.asyncapi()), not the registry snapshot
described on this page — despite the name, they don't call
build_registry_snapshot(). To print the registry snapshot from a
terminal, use the cosalette package CLI:
cosalette manifest myapp.main:app --registry (add --table for the
human-readable form). The cosalette_inspect_app MCP tool and calling
build_registry_snapshot() yourself remain available for programmatic use.
app.asyncapi() composes addresses from the topic prefix
app.asyncapi() accepts topic_prefix= and defaults it to app.name.
Because resolving settings.mqtt.topic_prefix requires settings, an
introspection surface that has not loaded them renders addresses under
the app name. That is correct for an app with no prefix configured, but
it is not what a prefixed app publishes to at runtime — pass the
resolved prefix when the addresses have to match the wire.
The registry snapshot is unaffected: it records identity (names, archetypes, trigger sources), never transport addresses. The identity-vs-address split is decided in ADR-072.
Formatting¶
Two convenience functions turn a snapshot into display-ready output:
from cosalette import (
build_registry_snapshot,
format_registry_table,
format_registry_json,
)
snapshot = build_registry_snapshot(app)
# Human-readable table
print(format_registry_table(snapshot))
# Indented JSON via orjson (same formatter the cosalette_inspect_app MCP tool uses)
print(format_registry_json(snapshot))
format_registry_table groups registrations by type (devices, telemetry,
commands, streams, periodic, adapters), omitting empty sections. Booleans are
rendered as ✓/— and missing values as —.
format_registry_json delegates to orjson with two-space indentation,
consistent with ADR-021.
Introspection Accessors¶
build_registry_snapshot() returns a flattened, serialised view. When you need the
live registration objects — with their full typed metadata — rather than a
serialised snapshot, the App exposes read-only accessor properties. Most are shared
with Router through a common mixin, so the same code works against either.
Registration collections¶
The sequence accessors return immutable point-in-time tuple snapshots, so
registry internals cannot be mutated through them. .adapters returns a live,
immutable MappingProxyType view (entries added later remain visible through it):
| Accessor | Return type | App | Router |
|---|---|---|---|
.devices |
Sequence[DeviceRegistration] |
✓ | ✓ |
.telemetry_registrations |
Sequence[TelemetryRegistration] |
✓ | ✓ |
.commands |
Sequence[CommandRegistration] |
✓ | ✓ |
.periodic_registrations |
Sequence[PeriodicRegistration] |
✓ | ✓ |
.stream_registrations |
Sequence[StreamRegistration] |
✓ | ✓ |
.state_factories |
tuple[StateRegistration, ...] |
✓ | — |
.adapters |
Mapping[type, ...] |
✓ | ✓ |
The _registrations suffix on telemetry_registrations and periodic_registrations
avoids shadowing the @app.telemetry / @app.periodic decorators.
.root_names returns a frozenset[str] of the names of root-level registrations
(is_root) across the device, telemetry, command, and stream archetypes. Root entities
occupy the app namespace with no device segment, so by the ADR-058 contract they never
appear in a schema's device_names; tooling that compares registrations against schema
device names (e.g. cosalette schema check) excludes them to avoid a spurious EXTRA.
# Assert registration metadata directly, without a snapshot
reg = next(r for r in app.commands if r.name == "valve")
assert reg.payload_model is ValveCommand
assert reg.state_model is ValveState
Name and configuration accessors¶
| Accessor | Return type | App | Router | Description |
|---|---|---|---|---|
.registered_names |
frozenset[str] |
✓ | ✓ | Every registered device/telemetry/command/periodic/stream name |
.settings_class |
type[Settings] |
✓ | — | The concrete Settings subclass, available before startup |
.store |
Store \| None |
✓ | — | Configured store backend (or None when explicitly opted out) |
.store_is_default |
bool |
✓ | — | True when the store was auto-resolved by the framework |
.has_dynamic_entities |
bool |
✓ | — | True when the app's entity set can vary between runs |
.retained_cleanup |
bool \| None |
✓ | — | Explicit ADR-048 cleanup override (True/False), or None for auto |
registered_names answers "is this name taken?"; the collection accessors expose the
metadata behind each name. All of these are stable public API — useful for structural
wiring tests, code generators, and diagnostics that reason about an app before it
starts.
Design Notes¶
The introspection module reads the App's internal registries directly.
It produces a read-only snapshot — no mutations, no side effects. The
output uses repr() on strategies and persist policies, which means
adding a custom strategy only requires implementing __repr__ for it
to appear correctly in snapshots.
Open/Closed Principle
New strategy or policy classes automatically work with introspection
as long as they implement __repr__. No changes to the introspection
module are needed — the system is open for extension, closed for
modification.