MQTT 3.1.1 / 5 clients reach the ClutchCall robotics mesh through an ingress bridge. Devices and applications speak MQTT to the broker; the bridge forwards matching topics onward into ROS 2 / Zenoh. You do not configure the forwarding in the client — you configure it with topic policies, which are per-pattern rules that the robotics engine (mod_ros2) reads to shape what it forwards and how. Three console screens cover the bridge: Policies are durable: they live in the mqtt_topic_policy table in Supabase behind row-level security, scoped to your orgId. Client and topic state is live broker state published by mod_mqtt through Redis — it is not history, and it disappears when the broker stops reporting it.

What the bridge forwards

Nothing is forwarded because a device published it. Forwarding is driven by the topic policy list. Each policy is a topic filter plus three delivery settings and an enable flag: A policy with enabled unchecked stays in the list and keeps its status, but is not applied. Use it to park a rule rather than deleting and retyping the pattern.

Topic filter syntax

The pattern field uses standard MQTT subscription syntax, and the console validates it against the same rules the broker applies to subscriptions before it will let you save:
  • + matches exactly one level, so it must occupy a whole level. robots/+/telemetry is valid; robots/rob+/telemetry is rejected.
  • # matches the rest of the tree, so it must also occupy a whole level — robots/+/telemetry/#, not robots/telemetry#.
  • # may only appear as the final level. Because it already matches everything below it, a # with levels after it is rejected.
Validation runs as you type but errors only render once you attempt to save. An empty pattern is reported as a missing required field rather than as a syntax error.

QoS preference

The qos field is the delivery guarantee the bridge prefers for topics matching the pattern: Exactly-once delivery costs a four-way handshake per message on the MQTT side, so reserve 0/1/2 for topics where a duplicate would actually cause harm — commands and state transitions rather than telemetry.

Retained handling: cache, pass, drop

MQTT lets a publisher mark a message retained, so that the broker serves the last value to any later subscriber. The retained field says what the bridge does with those payloads: cache is what you want for slow-changing state that a late joiner needs immediately (a configuration value, a mode, a last-known pose). pass suits streaming telemetry, where the previous sample has no value to a new subscriber. drop is for publishers that set the retained flag indiscriminately and would otherwise fill the retained store.

ACL inheritance and override

The acl field resolves against your workspace ACL configuration:
  • inherit — the pattern uses the workspace default publisher and subscriber lists from your ACL config. This is the default.
  • override — this rule carries its own publisher / subscriber list, and the workspace defaults do not apply to matching topics.
Keep rules on inherit unless a pattern genuinely needs a narrower or wider audience than the rest of the namespace. Overrides are the part of this table that is easiest to forget about later.

Policy status: active, pending, error

Every row carries a server-reported status. The console renders and filters on it; it never sets it. The filter chips above the table (All rules, Active, Pending, Error) narrow the list and show a count per status, which is the quickest way to find a rule that failed to land in a long list. Two different failures surface in two different places:
  • Rejected at save time. A missing or malformed pattern is caught in the console before the mutation is sent. Anything the server rejects — a conflicting pattern, a permission problem — comes back as the mutation’s error message, rendered inline next to the Save policy button. Nothing is written.
  • Stored but not applied. The row is created and then shows error. The policy exists in Supabase; the engine did not take it. Re-saving the row with upsert is the retry path.
A row that stays pending has been written to Supabase but the engine has not acknowledged it. The policy list does not poll — it refetches only after your own create or delete — so reload the screen before concluding that a rule is stuck. Deleting asks for confirmation and then removes the row by id. There is no undo; the pattern has to be retyped.

What the client list reports

The MQTT clients screen polls mqttClients.list every 10 seconds and renders one row per connected client. Today the procedure reports three things per client: The KPI strip above the table derives Connected from the row count. Everything else in the strip reads — for the reason described below.

Fields that are not reported yet

The client table keeps columns for fields the broker does not yet expose through this procedure. They render blank or as — rather than being filled with assumed protocol defaults, which would be worse than an empty cell:
  • Auth (authenticated username)
  • Source IP
  • MQTT (protocol version — 5 or 3.1.1)
  • QoS pref
  • Retained (per-client retained topic count)
  • Hz (recent publish rate)
  • Per-row connection status (active / idle / reconnecting)
  • The Inflight QoS 1/2 KPI, which is not wired to a source at all
Consequently the MQTT version and Retained set KPIs read — and are labelled not reported. If you need to attribute traffic to a source address or an authenticated user, the client list is not the place to do it today.

Forcing a client off

Select one or more rows — click anywhere on a row, or use its checkbox — and use Disconnect selected in the page header. The button stays disabled until at least one row is selected. This calls mqttClients.disconnect with your orgId and the selected client ids. It enqueues a kick; the engine drops those clients on its next tick. The console then clears the selection and refetches the list, so a successful kick shows up as the row disappearing on the next render. Two consequences of the procedure’s shape are worth knowing before you use it:
  • The mutation takes only orgId and clientIds. There is no ban duration, blocklist, or reason parameter — a disconnect is a one-time drop, not a revocation. Nothing in this screen stops the same client id from connecting again immediately.
  • Because Inflight QoS 1/2 is not reported, the console cannot tell you what was in flight for a client at the moment you kicked it. Do not use this screen to reason about message loss; use it to clear a client you know is misbehaving.

The topic namespace view

The Topics screen polls mqttTopics.list every 10 seconds and receives a flat list of topic strings. Its only metric is Distinct topics, the number of entries after filtering. The search box in the header does a case-insensitive substring match over that flat list before the tree is built, so filtering to fleet/ collapses the tree to just that subtree. The chips below the KPI strip (Retained, QoS 2 active, High rate, No subscribers) are placeholders for filters the flat topic list cannot yet support; they are not wired to a handler.

How the topic tree is built

The tree is derived entirely in the browser. Each topic string is split on / and the segments are merged into a nested structure, so fleet/rob-01/telemetry and fleet/rob-01/pose render as one fleet node with one rob-01 child and two leaves. Empty segments are skipped. That means the tree shows shape, not identity: an intermediate node like rob-01 exists because something below it was published, and does not imply a topic at that level. Node metadata renders as —, and the retained and subscription chips have no data source in this view.

Counters that read as a dash

Messages / s, Retained set and Bytes / s are all — on the Topics screen. Rate, byte and retained counters are not tracked by the bridge subset that backs this view. The same applies to per-node metadata in the tree. Nothing in the console derives these numbers, and nothing caches a previous value — a — means “not measured”, never “measured as zero”. For quantitative traffic questions, go to the mesh-side telemetry instead of this screen. Related: the topic list is a live snapshot. The console keeps no client-side history, so a topic stays listed for exactly as long as mqttTopics.list keeps returning it, and vanishes on the first poll that omits it.

Troubleshooting an empty topic tree

An empty tree renders as “Your topic tree is empty — no devices are publishing yet.” Work through it in this order:
  1. Check the filter box. The empty state also appears when the substring filter matches nothing. Clear it first.
  2. Check the clients screen. If no clients are listed, this is a connectivity or credentials problem at the broker, not a bridge problem. The topic list cannot contain anything if nothing is connected.
  3. Check that clients are connected but subscribing only. A row’s last-activity cell shows a subscription count. Subscribers alone do not populate the topic namespace; something has to publish.
  4. Check your policy statuses. Filter Topic policies by Error and Pending. A pattern that never reached the engine forwards nothing.
  5. Check the pattern actually matches. robots/+/telemetry matches exactly one level where the + sits — it will not match robots/rob-01/left/telemetry. Use a trailing # when the depth below a level varies.
  6. Check enabled. A disabled policy still renders with its status chip, which makes it easy to read a parked rule as a live one.
  • Authentication — API keys and relay tokens for the control and data planes
  • Telemetry — metrics, traces, and packet capture streams