# MQTT ingress bridge and topic policies

> How the MQTT ingress bridge forwards topics into ROS 2 / Zenoh, how topic policies control QoS, retained payloads and access, and what the console reports per client and per topic.

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:

| Screen           | Backed by                                     | What it is for                                        |
| ---------------- | --------------------------------------------- | ----------------------------------------------------- |
| Topic policies   | `fleetRobotics.mqttTopicPolicies.*`           | The rules. Create, edit, delete, and see their status. |
| MQTT clients     | `fleetRobotics.mqttClients.list` / `.disconnect` | Who is connected right now, and kicking them off.  |
| Topics           | `fleetRobotics.mqttTopics.list`               | The live topic namespace as a tree.                    |

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:

| Field      | Values                          | Default  |
| ---------- | ------------------------------- | -------- |
| `pattern`  | An MQTT topic filter            | required |
| `qos`      | `0`, `1`, `0/1`, `0/1/2`        | `0/1`    |
| `retained` | `cache`, `pass`, `drop`         | `pass`   |
| `acl`      | `inherit`, `override`           | `inherit`|
| `enabled`  | boolean                         | `true`   |

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:

| Setting | Meaning                                                        |
| ------- | -------------------------------------------------------------- |
| `0`     | Fire-and-forget. Lowest latency.                                |
| `1`     | At-least-once.                                                  |
| `0/1`   | Accept either level 0 or level 1. This is the default.           |
| `0/1/2` | Also allow exactly-once. Use it only when it is genuinely needed. |

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:

| Setting | Behaviour                                                        |
| ------- | ---------------------------------------------------------------- |
| `cache` | Keep the last value and serve it to subscribers on subscribe.     |
| `pass`  | Forward the message without storing it. This is the default.      |
| `drop`  | Ignore the retained flag entirely.                                |

`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.

| Status    | Shown as      | Read it as                                              |
| --------- | ------------- | ------------------------------------------------------- |
| `active`  | green chip    | The engine has the rule and is applying it.              |
| `pending` | neutral chip  | The rule is stored but not yet in force on the engine.    |
| `error`   | red chip      | The rule was stored but could not be applied.             |

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:

| Column          | Source                                                        |
| --------------- | ------------------------------------------------------------- |
| Client          | The MQTT client id. A client that connected without one is shown as `(anonymous)`. |
| Connected       | The connect timestamp, formatted in your locale.                |
| Last activity   | The client's current subscription count, e.g. `3 subs`.         |

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.

## Related

- [Authentication](/concepts/authentication) — API keys and relay tokens for the control and data planes
- [Telemetry](/platform/telemetry) — metrics, traces, and packet capture streams
