diff --git a/README.md b/README.md
index 5db79ae..7c08003 100644
--- a/README.md
+++ b/README.md
@@ -128,32 +128,55 @@ Each component runs inside its own `ATask` at a configurable interval
keyed WebSocket channel that any connected client can subscribe to and send
commands back on (e.g. `{"TempCtrl": {"Soll": {"Temp": 65}}}`).
-### State replay on connect
+### State is server-only; GUIs only reflect it
-A fundamental assumption baked into this protocol: a client never knows what
-it's connecting to. The GUI might be the first thing to ever talk to a
-freshly started server, or it might connect to one that's been running
-unattended for hours, mid-brew, with a schedule already loaded and paused
-partway through step 4. A client can also disconnect (network hiccup, the
-user closing and reopening the GUI) and reconnect later, at any server state,
-with no special "resume" handshake. In every one of these cases, the client
-must end up with the *same* full picture of current state as one that's been
-connected the whole time - not just whatever changes happen after it joins.
+**The server is the single source of truth for all state. Neither GUI client
+ever maintains its own model of what the server should be doing.**
-This is what `WsServerMultiUser.global_state` (`ws/server/ws_server_multi_user.py`)
-exists for: every message ever broadcast over the dispatcher is merged into
-it (`ws/user.py`'s `update()`), keyed exactly like the live channels are.
-Subscribing to a channel (`{"+": "Sud"}`, sent automatically for every
-channel on connect - see `MessageDispatcherSync(auto_subscribe=True)`)
-doesn't just start a future stream of changes; the server immediately
-replays the *entire* currently-known state for that channel in one message,
-via `User.send()`'s `dpath` search over `global_state`. A reconnecting
-client gets exactly the same replay a brand-new one would, because the
-server doesn't distinguish between the two cases at all - there's no
-"first connect" special-casing anywhere in this path.
+When a user moves a slider or clicks a button, the client sends a command
+and *waits* for the server's reply before updating any display — the reply
+is the only authority. Consequences:
-The corollary, easy to miss: this mechanism has no concept of "transient" -
-anything sent through it is implicitly durable state that will be replayed
+- **Multiple clients stay in sync automatically.** Every state change is
+ broadcast to all connected clients simultaneously. A second browser tab
+ and the desktop GUI connected to the same server both see the same update
+ at essentially the same time, with no cross-client coordination needed.
+- **Controls always reflect the last value the server pushed**, not what the
+ user last touched. `ChangedFloat`/`ChangedInteger` wrappers on the server
+ side mean a broadcast only goes out when a value actually changes — the
+ client never needs to suppress its own echoes, since the server handles that.
+- **No special "resume" handshake is needed on reconnect**, because the
+ client never diverged from the server in the first place — it only ever
+ holds what the server pushed.
+
+The Closed-loop checkbox is a concrete example: clicking it sends
+`{Heater: {ClosedLoop: false}}`, but the checkbox's own display is only
+updated when the server echoes `{ClosedLoop: false}` back through
+`onHeaterChanged()`. If the command is lost or refused, the UI stays at
+whatever the server last reported — no local flag, no "optimistic update."
+
+**State replay on connect** is how a freshly-connected client gets that
+full server picture instantly. The GUI might be the first thing to ever
+talk to a freshly started server, or it might connect to one that's been
+running unattended for hours, mid-brew, with a schedule already loaded and
+paused partway through step 4. A client can also disconnect and reconnect
+later, at any server state. In every case the client must end up with the
+*same* full picture as one that's been connected the whole time.
+
+This is what `WsServerMultiUser.global_state`
+(`ws/server/ws_server_multi_user.py`) exists for: every message ever
+broadcast over the dispatcher is merged into it (`ws/user.py`'s `update()`),
+keyed exactly like the live channels are. Subscribing to a channel
+(`{"+": "Sud"}`, sent automatically for every channel on connect - see
+`MessageDispatcherSync(auto_subscribe=True)`) doesn't just start a future
+stream of changes; the server immediately replays the *entire*
+currently-known state for that channel in one message, via `User.send()`'s
+`dpath` search over `global_state`. A reconnecting client gets exactly the
+same replay a brand-new one would — there's no "first connect"
+special-casing anywhere in this path.
+
+One corollary, easy to miss: this mechanism has no concept of "transient".
+Anything sent through it is implicitly durable state that will be replayed
to whoever connects next, even long after the fact. A one-shot *event*
(something that happened, rather than something that's currently true) has
to be modeled as a value that gets explicitly cleared back to neutral right
@@ -318,12 +341,46 @@ running schedule - refused (with an explicit, one-shot `Error` reply - see
than the client trying to guess that from its own view of the state.
The temperature controller has a master enabled switch (off by default -
-see `components/pid/temp_controller_base.py`): `SudTask` enables it for as
-long as a run is in progress (any state other than idle/finished) and
-disables it again - forcing the heater output to 0 - the moment it stops or
-finishes, handing control back to manual mode. The GUI's Manual tab has an
-"Enabled" checkbox for driving this directly outside of a Sud run; while
-disabled, its temperature/heat-rate setpoint controls are inactive.
+see `components/pid/temp_controller_base.py`). `HeaterTask` owns this
+switch via its `closed_loop` flag: the TC is enabled whenever the system is
+in closed-loop mode (the default) and disabled in open-loop mode. This is
+wired up in `server/brewpi.py` via
+`heater_task.set_on_closed_loop_changed(tc.set_enabled)`, and fired once
+at startup from `HeaterTask.on_process()` so the TC is live immediately.
+`SudTask` no longer manages the TC's enabled state — its
+`on_state_changed()` only stops the stirrer when a run ends. See "Heater
+control modes" below.
+
+### Heater control modes
+
+The heater operates in one of two modes, selectable by the user while the
+Sud is paused or stopped. Switching while a run is active is blocked —
+the mode is automatically reset to Closed-loop the moment a run starts
+(the client sends `{Heater: {ClosedLoop: true}}` before `{Sud: {Start: true}}`).
+
+**Closed-loop** (default): the temperature controller drives the heater
+exclusively. Its output `y` flows to `HeaterTask.actor()` — `power_soll`
+(any direct power command from a client) is ignored entirely. While paused
+or stopped, the user can adjust the temperature setpoint and heat rate;
+the heater-power slider is disabled. Because the TC is always enabled in
+this mode (see above), adjusting the temperature setpoint even with no Sud
+loaded or running causes the heater to respond immediately.
+
+**Open-loop**: the TC is disconnected — `HeaterTask` uses `power_soll`
+(the last `{Heater: {Power: X}}` received from a client) instead of
+`power_actor` (the TC output), and the TC is disabled (`y` forced to 0).
+Only the heater-power slider is active; temperature and heat-rate controls
+are disabled. Only available when the Sud is paused or stopped.
+
+The `ClosedLoop` boolean is part of the server's `global_state` and is
+broadcast to every client on connect, so all clients always see the same
+mode (see "State is server-only" above) — flipping the checkbox in one
+browser tab is immediately reflected in every other connected client.
+
+On every resume from pause, `SudTask.recv()` restores the current step's
+schedule parameters (temperature setpoint, heat rate, stirrer) before the
+run continues, overwriting any manual adjustments the user made while
+paused.
### Forecast vs. actual duration
@@ -531,22 +588,45 @@ in its own daemon thread, `--http-port`, default `8080`) needed adding,
deliberately independent of the existing `websockets`-based asyncio server
so neither can affect the other's behavior or availability.
-v1 covers only the Manual tab (direct heater/stirrer/controller control)
-and the Progress tab (above) - the two most actionable surfaces, and
-neither needs a charting library. `web/app.js` ports the relevant logic
-from `client/brewpi_gui.py` directly: `components/sud.py`'s
+The browser client covers the Manual panel (direct heater/stirrer/TC
+control) and the Progress tab (above). `web/app.js` ports the relevant
+logic from `client/brewpi_gui.py` directly: `components/sud.py`'s
`_build_step()`/`_merge_defaults()` (the raw doc from `Sud.Json` is
-unresolved - every step still needs `default.step` merged in), and the
+unresolved — every step still needs `default.step` merged in), and the
`StepPlate`/`_update_step_plates()`/`update_status_step_label()` logic
behind the Progress tab and status line. New/Load/Save (a plain
``/`FileReader` and a `Blob`/`` standing in
for the desktop app's native file dialogs), Start/Pause/Stop, and Confirm
(a plain `