plenum-architecture-contract
A source-of-truth reference for Plenum system design, invariants, and architectural constraints to guide safe development.
Install
mkdir -p .claude/skills/plenum-architecture-contract && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/16699" && unzip -o skill.zip -d .claude/skills/plenum-architecture-contract && rm skill.zipInstalls to .claude/skills/plenum-architecture-contract
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
The load-bearing design decisions of Plenum (smart-thermostat-with-vents), the invariants that must hold, WHY each exists, and the known-weak points. Load when you need the canonical temperature-unit contract (°F storage, single write boundary, #231), the component map with real file paths, the cycle-engine invariants (mode lock, in-place trigger updates, 60s tick), the MCP loopback design, or a "before you change X, understand Y" dependency check. Also load when CLAUDE.md prose seems to disagree with the repo — this skill flags the known drift.Key capabilities
- →Identify component responsibilities within the Plenum system
- →Understand the temperature-unit contract (all °F internally)
- →Trace the cycle-engine invariants and state machine
- →Analyze the MCP loopback design
- →Identify known weak points and dependencies
How it works
This skill documents Plenum's load-bearing design decisions, architectural invariants, and component map, explaining the rationale behind each and highlighting known weak points, with verification against the current codebase.
Inputs & outputs
When to use plenum-architecture-contract
- →Understanding architectural invariants
- →Checking system dependencies
- →Validating design changes
About this skill
Plenum architecture contract
The design decisions this system stands on, stated as invariants with their
rationale. Every claim below was verified against the repo at v0.22.1
(2026-07-04). Where DESIGN.md prose has drifted behind the code (or
CLAUDE.md had, before the 2026-07-05 corrections in PR #388), the drift is
called out — the repo files win.
When NOT to use this skill:
- Domain theory (why short-cycling kills compressors, what a deadband is, HA
entity semantics) →
hvac-zoning-reference. - The blow-by-blow story of a past incident →
plenum-failure-archaeology. - Which CI gates a change must clear, PR/release mechanics →
plenum-change-control. - Diagnosing a live symptom →
plenum-debugging-playbook. - Adding/locating a config knob →
plenum-config-and-flags.
Jargon (once): write boundary — the POST/PUT/PATCH handlers in
smart_vent/backend/api/routes.py, the only place display-unit input becomes
storage °F. Delta — a temperature difference (deadband, offset): °C↔°F scales
by 9/5 with no 32 offset. Ingress — Home Assistant's authenticated
reverse proxy that serves the add-on UI. Zone — one thermostat plus its rooms.
1. Component map (verified file paths and responsibilities)
All backend paths are under smart_vent/backend/.
HA Core ◄─ WebSocket + REST ─► ha_client.py ◄── engine/ ◄── scheduler.py ◄── api/routes.py ◄── frontend / MCP
│ │ │
└──────────────┴── db.py ── app.db (SQLite, DATA_DIR)
| File | Responsibility (verified) |
|---|---|
main.py | Entry point. Binds aiohttp on PORT (default 8099), starts the HA client task, the Scheduler, and the HTTP MCP server (uvicorn) on MCP_PORT (default 9099). One-shot flair.db → app.db rename (#89). Security-headers middleware on every response. |
ha_client.py | Raw HA WebSocket client + state cache. Normalises sensor readings to °F via per-entity unit_of_measurement (get_numeric_state, line ~172), resolves HA's system unit into ha_temp_unit from /api/config (#281), and converts stored-°F setpoints back to HA-native units on write via _setpoint_to_native (#280). Dev mode intercepts all writes here and logs [DEV] Would … instead. |
scheduler.py | Orchestrator. Owns _engines: dict[thermostat_id → CycleEngine], the 60s APScheduler main_tick, log purge (24h), schedule-expiry sweep (60s, #359), daily/monthly metrics rollups (#85). Owns _active_unit and the system_enabled / developer_mode flags (persisted in system_settings). |
engine/cycle_engine.py | One instance per thermostat. The IDLE → RUNNING → TERMINATING → IDLE state machine; all safety guards (short-cycle lockout, cooling lockout, staleness filter, cycle timeout, airflow floor, #367 envelope enforcement) live in its tick. ~3600 lines. |
engine/room_manager.py | Resolves each room's active trigger: override > schedule > presence holdover. |
engine/vent_controller.py | Issues cover open/close commands per the room-vent control_method. |
api/routes.py | All REST endpoints. The write boundary: every temperature field is converted via the units.py helpers here, then bounds-checked on the °F value. Also TEMPERATURE_FIELDS (Python side of the parity system). |
api/ws_handler.py | Thin WebSocket broadcaster (WSManager). Event payloads are built in scheduler.py / the engine — temperatures in them are raw °F. |
units.py | The four converters: to_f / delta_to_f / from_f / from_f_delta. Single source of truth shared by routes.py, ha_client.py, and the engine's HA-ingest helper. |
db.py | aiosqlite layer. SCHEMA script (WAL mode, FK on) + append-only _MIGRATIONS list of ALTER TABLE statements + two one-shot data migrations gated by system_settings sentinels. |
tz.py | Single source of local-time truth: TZ env (from the timezone add-on option via run.sh). Schedule matching and metrics bucketing use it; storage timestamps are UTC. |
mcp_http.py / mcp_openapi.py | HTTP (Streamable) MCP server on port 9099; tools generated from the OpenAPI spec, each call dispatched over loopback HTTP to the REST API. |
frontend/src/contexts.ts | buildUnitContext(unit) — the frontend display-conversion layer (toDisplay, toDisplayDelta, fmtTemp). |
DESIGN.md is the original design doc and is stale in places (it still
describes a stdio/SSE MCP server, min_open_vents as a count, a 30s monitor
tick). Treat it as historical intent; this skill and the code are current.
2. The temperature-unit contract (canonical statement — owned here)
Invariant: every temperature at rest or in flight inside the system is °F. SQLite rows, engine arithmetic, scheduler state, WebSocket event payloads, API GET responses — all °F, always (Issue #123). Exactly three places convert, and nothing else may:
- Backend write boundary (
api/routes.py): display-unit input → °F viato_f/delta_to_f(imported fromunits.pyas_to_f/_delta_to_f), using the active unit fromscheduler.get_temperature_unit(). Bounds (40–90 °F for targets, 40–100 °F for setpoints, 0–10 °F for deadband_override) are checked after conversion, on the °F value — checking the raw input is meaningless when the unit varies (.jules/sentinel.md, 2026-05-05). - Frontend display layer (
contexts.ts): °F → display viatoDisplay/toDisplayDelta/fmtTemp, for rendering and form initialisation only. Forms hold display-unit values and submit them raw. - HA I/O boundary (
ha_client.py+_climate_temp_to_fincycle_engine.py): HA-native values ↔ °F. Sensor entities carry a per-entityunit_of_measurement;°Creadings are normalised through the sharedto_f(#251). Climate entities report in HA's system unit (ha_temp_unit, resolved from/api/config— #281 fixed the detection), so climate reads pass through_climate_temp_to_fand setpoint writes through_setpoint_to_native(#280 — sending raw °F to a metric HA would be interpreted as °C and drive the HVAC wildly wrong).
The frontend NEVER calls toStorage / toStorageDelta on an outgoing
payload. That was #231: the Thermostats form converted 16 °C → 60.8 °F
via toStorage, then the backend's _to_f converted again → 141.44 °F
stored. Each side's unit tests passed because each asserted a different
contract. The conversion belongs to exactly one side — the backend. The
toStorage helpers still exist in contexts.ts but are reserved for local
validation-bound math only.
Absolute vs delta is a type distinction, not a detail. Absolute uses
(f−32)×5/9; a delta uses only ×5/9. Feeding a delta (deadband, temp_offset,
overshoot_delta) through the absolute converter renders a 2 °F deadband as a
negative °C number (#291 was exactly this in a chart subtitle). units.py
keeps all four converters side by side to make the axis visible.
The parity system keeps the contract enforced: every write-boundary
temperature field is registered in TEMPERATURE_FIELDS in both routes.py
(12 fields as of v0.22.1) and e2e/tests/temperature-fields.ts, with
// @covers: round-trip tests, and
backend/tests/test_temperature_field_parity.py fails CI on any drift. The
add-a-field procedure and gates are owned by plenum-change-control; the
parity-rule details by plenum-validation-and-qa.
History note: older CLAUDE.md copies placed _to_f/_delta_to_f/_from_f
in backend/api/routes.py; CLAUDE.md was corrected 2026-07-05 (PR #388). They
live in backend/units.py and are imported into routes.py under those aliases
(routes.py lines 35–38). Same semantics; trust units.py.
3. Engine and scheduler invariants
| Invariant | Why | Where verified |
|---|---|---|
One CycleEngine per thermostat; 60s tick. The scheduler's _sync_engines creates/removes engines as thermostats appear in room configs; main_tick runs every 60s (max_instances=1, coalesce=True) and also fires on HA state changes. Each engine serialises its work behind an internal asyncio.Lock. | Zones are independent failure domains; one thermostat's outage must not stall another's control loop (#286 hardened the per-engine exception isolation). | scheduler.py lines ~53, 130–137, 470–481; cycle_engine.py tick() line 176. |
Mode is locked at cycle start (_cycle_mode, cycle_engine line 100). Monitoring, at-target checks, and the setpoint all use the locked mode, not live hvac_action. | During HVAC idle phases the naively inferred mode can flip tick-to-tick → heat/cool oscillation. A room that now needs the opposite direction is dropped by the mode filter; if no compatible rooms remain the cycle ends cleanly and a new one may start opposite on the next tick. | docs/cycle-engine.md §"Why the mode is locked"; cycle_engine.py lines ~404, 546, 566. |
Mid-cycle trigger changes are applied in place, never by teardown (#215). A changed source/target updates the running cycle and re-derives the setpoint; the cycle log stays open with a trigger updated in place setpoint-history entry. | The old teardown was itself a compressor stop/start — and with the #208/#214 off-time lockout enabled, the rebuild was then blocked for the lockout window, leaving a room unconditioned (back-to-back schedule blocks at 08:00 caused a full furnace stop→start for a target that only moved up). | cycle_engine.py lines ~886–911, 972; docs/safety.md §"In-place cycle updates". |
The engine never converts display units. It receives °F from the DB and commands °F; ha_client.py translates to HA-native at the wire. The only conversion helpers it touches are the HA-ingest normalisers (_climate_temp_to_f, to_f on °C sensors). | Conversion in two layers is how #231 happened; the engine staying unit- |
Content truncated.
When not to use it
- →When seeking domain theory (use hvac-zoning-reference)
- →When wanting the story of a past incident (use plenum-failure-archaeology)
- →When needing CI gates or PR mechanics (use plenum-change-control)
Limitations
- →The repo files win if CLAUDE.md prose seems to disagree with the repo.
- →DESIGN.md is the original design doc and is stale in places.
How it compares
This approach provides a canonical statement of Plenum's architecture, including specific file paths and responsibilities, unlike general system documentation that might be outdated or less detailed.
Compared to similar skills
plenum-architecture-contract side by side with the closest alternatives in the catalog.
| Skill | Installs | Updated | Safety | Difficulty |
|---|---|---|---|---|
| plenum-architecture-contract (this skill) | 0 | 1mo | No flags | Advanced |
| llm-council | 4 | 7mo | Review | Advanced |
| project-development | 1 | 3mo | Review | Advanced |
| decision-critic | 1 | 6mo | No flags | Advanced |
Try saying
Example prompts that trigger this skill in your AI assistant.
You might also like
llm-council
am-will
Orchestrate a configurable, multi-member CLI planning council (Codex, Claude Code, Gemini, OpenCode, or custom) to produce independent implementation plans, anonymize and randomize them, then judge and merge into one final plan. Use when you need a robust, bias-resistant planning workflow, structured JSON outputs, retries, and failure handling across multiple CLI agents.
project-development
muratcankoylan
This skill should be used when the user asks to "start an LLM project", "design batch pipeline", "evaluate task-model fit", "structure agent project", or mentions pipeline architecture, agent-assisted development, cost estimation, or choosing between LLM and traditional approaches.
decision-critic
solatis
Invoke IMMEDIATELY via python script to stress-test decisions and reasoning. Do NOT analyze first - the script orchestrates the critique workflow.
cc-skill-project-guidelines-example
davila7
Project Guidelines Skill (Example)
mcp-builder
anthropics
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
architecture-patterns
wshobson
Implement proven backend architecture patterns including Clean Architecture, Hexagonal Architecture, and Domain-Driven Design. Use when architecting complex backend systems or refactoring existing applications for better maintainability.