Status: telemetry SHIPPED. See ../MQTT.md for the operator page. Alert delivery over MQTT remains unbuilt and is still judged redundant for the reason given below.
Three things this design got wrong, found by building it. They are left inline below rather than edited out, because the pattern matters more than the individual errors.
- The
Queue: niltrap is real but INERT.autopaho/auto.go:271does substitutememory.New()— still true in the pinned v0.23.0, and the field's own comment contradicts the code. But the queue is read only byPublishViaQueue(auto.go:488).ConnectionManager.Publish(auto.go:460) bypasses it entirely and returnsConnectionDownErrorwhen the link is down, which is exactly the no-buffering behaviour this design wanted. No custom no-op queue is needed. The claim that shippingQueue: nil"would have produced exactly the stale-replay behaviour it argued against" is wrong — it would have produced an idle allocated queue and correct behaviour. The verification pass found a true code fact and drew a false conclusion from it.- 4 hex of sha256 is too few, and the arithmetic is not close. 16 bits gives a birthday collision probability of about n²/2/65536: ~1.9% across 50 altered names, ~68% across 300. Shipped with 8 hex (32 bits, ~0.0001% across 100). The design was right that the hash is necessary and wrong about its width.
- A structural aliasing hole the design did not see. A source named literally
twitch-1a2b3c4dslugs to itself unaltered, so it collides with any name whose hash happens to be1a2b3c4d. Closed by hashing anything already shaped like a suffix, which makes equality of the output imply equality of both halves.Everything else held: the dependency really is one net-new module (+586 KB on 25.4 MB, +2.4%, measured against a paired build);
paho.golangreally is MQTT 5.0 only; QoS 1 really is required for retained state; andengine.Statusreally did carry no source name.
Status: proposed, not started. Effort: 7–10 days, or 5–6 days for telemetry alone, which delivers most of the value. Dependency: exactly one net-new module.
Build retained telemetry first. Add MQTT alert delivery second, as a small rider on the same connection. They are not equal in value and not equal in cost.
Alert-over-MQTT is largely redundant: FormatJSON already posts a structured
body to any URL, and every MQTT-shaped deployment — Home Assistant, Node-RED,
n8n — can consume a webhook and republish it.
Retained telemetry has no existing path at all. The event broker
(internal/events) is in-process and drops for slow subscribers by design; the
WebSocket needs a live browser. Nothing in polyemesis can currently answer "is
the ingest up?" to a consumer that was not connected when the state changed.
That is precisely what retain=1 is for, and it is the only reason a Home
Assistant dashboard still shows the right thing after an HA restart.
Take github.com/eclipse/paho.golang, pinned, using the autopaho
subpackage. Do not hand-roll, and do not take paho.mqtt.golang.
Measured on this machine, not quoted: paho.golang's build graph is three
modules — itself, gorilla/websocket, and golang.org/x/net. polyemesis
already has both of the latter (gorilla/websocket is direct, used by
internal/api/ws.go).
Net-new modules: exactly one.
That is a materially easier case than any other item on this roadmap, and it clears the project's dependency bar comfortably.
Correction from verification — state this in the docs.
paho.golangis MQTT 5.0 only. Its own README: "This client aims to implement the MQTT Version 5.0 Specification." The original design never said so anywhere, while citing MQTT 3.1.1 throughout for retained-message, QoS and topic behaviour. The substance survives — those rules are identical in v5 — but the design must declare the protocol version it speaks, because a broker pinned to 3.1.1 will not talk to this client at all.
New package internal/mqtt owns the paho dependency. internal/alerts must
never import paho — it stays testable without a broker, exactly as it is
testable without a socket today via its Doer seam.
// Publisher is the MQTT side, narrowed the same way Doer narrows HTTP so a
// test can decode wire bytes without a broker.
type Publisher interface {
Publish(ctx context.Context, topic string, qos byte, retain bool, payload []byte) error
Connected() bool
}Key configuration choices:
| field | value | why |
|---|---|---|
ClientID |
polyemesis-<instanceID> |
collision is the number-one cause of mystery reconnect loops |
KeepAlive |
30 | loss of connectivity is otherwise not detected |
WillMessage |
polyemesis/<instance>/status = offline, QoS 1, retained |
the entire availability story |
ReconnectBackoff |
exponential 1 s → 30 s | mirrors the existing Notifier backoff |
ConnectPassword |
from secrets.Box |
never from the URL |
For retained telemetry, buffering is wrong: a queued 90-second-old bitrate replayed on reconnect is worse than none, because the next tick republishes ground truth anyway. For alerts the buffer already exists one layer up in the coalescer.
Correction from verification — this is a trap. The original design set
Queue: niland documented it as "deliberate; no offline buffering". That is a no-op.autopaho/auto.go:271readsif cfg.Queue == nil { cfg.Queue = memory.New() }— autopaho silently substitutes an in-memory queue. Suppressing buffering requires an explicit no-op queue implementation, not a nil. Shipping the design as written would have produced exactly the stale-replay behaviour it argued against, while the comment claimed otherwise.
polyemesis/<instance>/status retain, QoS 1 "online" | "offline"
polyemesis/<instance>/state retain, QoS 1 host JSON
polyemesis/<instance>/source/<slug>/state retain, QoS 1 per-source JSON
polyemesis/<instance>/source/<slug>/dest/<slug>/state retain, QoS 1 per-destination JSON
homeassistant/device/<instance>/config retain, QoS 1 HA discovery
Everything is QoS 1, not QoS 0. A conforming broker may decline to store a retained QoS 0 message, and retained state that must survive a broker restart therefore has to be QoS 1.
Slug() is the single chokepoint. Home Assistant's node/object id charset is
[a-zA-Z0-9_-]; MQTT topic names must contain no +, no #, no NUL.
- lowercase,
[a-z0-9_-]kept, everything else →-, runs collapsed, trimmed; - empty result →
x; - append 4 hex of
sha256(original)whenever the slug is not byte-identical to the input. Two destinations namedTwitch (main)andTwitch [main]must not collide — a collision silently overwrites one HA entity with another; - assemble topics from a slice of already-slugged non-empty segments, never
strings.Joinover possibly-empty parts. A trailing/is a distinct topic, which would split telemetry into two streams no subscriber filter matches; - reject a prefix beginning with
$: a subscriber using#never receives$-prefixed topics, making the telemetry invisible in exactly the debugging scenario where you would go looking for it.
Beyond the two already inline above:
- The wildcard requirement ID is
MQTT-4.7.1-3, not-2. The substance is right; the identifier was wrong, and the design proposed writing these IDs into validation error messages. - Home Assistant does not "recommend" device discovery over per-component discovery. Its docs present them as alternatives. Do not attribute a preference the source does not state.
- The go-rtmp rejection lives in
DESIGN-ONE-PORT-ONLY.md, not
DEPENDENCIES.md. Cite the right file. - Line anchors into
internal/alertshad drifted 30–60 lines, and the design referred to aCoalescertype that does not exist — it is an unexportedcoalescer. Re-derive every anchor before implementing. engine.Statuscarries no source name andEngineexposes no accessor for one; onlySourceID()andSource(). The topic tree needs a name, so that gap has to be closed first.- The "~120-line rider" and the 7–10 day estimate are unmeasured guesses in a design that is otherwise explicit about what it measured. Treat them as looser than the rest.
- Wire-level, no broker. An in-test MQTT 5.0 server stub; assert exact topic strings, the retain flag, and QoS on every publish. Retain is the whole feature and it is one bit — assert the bit.
- Slug collision. Table test over adversarial names (
Twitch (main)vsTwitch [main], empty, all-punctuation,$SYS, 300 chars) asserting distinct topics. - Availability. Kill the process without a clean shutdown and assert a
subscriber receives the retained
offlinewill message — the case the whole design exists for. - Restart survival. Publish state, restart the broker, reconnect a fresh subscriber, assert it immediately receives current state. This is what QoS 1 retained buys and what QoS 0 would silently fail.
- No credential leaks. Run the existing
redactsuite over every published payload; assert no stream key, token or password appears on any topic. - Acceptance against a real
mosquittoin CI.
- Retained messages persist on the broker after polyemesis forgets them. A renamed or deleted source leaves an orphan retained topic and a stale HA entity forever unless a zero-byte delete is published. The cleanup path must be designed, not bolted on.
- Credentials on a topic tree. Redaction is not optional, and MQTT has no equivalent of the webhook URL masking already in place.
- MQTT 5.0 only — see above.
- A second buffering layer if the
Queuetrap is not handled explicitly.
- ROADMAP
- ../MONITORING.md — the existing Prometheus and webhook paths
- ../DEPENDENCIES.md — the bar this must clear