# Ingress and trust

> How Fermix checks incoming messages, decides who is allowed to use the agent, and carries that trust level through to which tools the agent can run.

This page explains what happens to a message after it arrives, before the agent ever sees it: Fermix checks who sent it, what they are allowed to do, and prepares it for the agent. Every inbound message, regardless of channel, passes through a single entry point: `Gateway.ingest/2`. It normalizes the payload (puts it into one standard shape), authorizes the sender, ingests media (transcribes audio, downloads and attaches images, coalesces multi-image messages), dispatches slash commands, and hands the turn to the FIFO queue (a first-in, first-out line, so messages are handled in order). Nothing reaches the agent without clearing this path.

## The Gateway pipeline

`Gateway.ingest/2` processes each message in this order:

1. **Normalize.** A raw map or `Message` struct is validated and converted into a `FermixChannels.Gateway.Message`. Required fields are `id`, `content`, `sender`, `channel`, `chat_id`, and `reply_target`; any missing field returns `{:error, {:invalid_message, field}}` and the message is dropped with an error log.

2. **Authorize.** `Gateway.Authorizer.resolve/1` maps the source to an `Authorization` struct (`%{role, trust}`). Denied senders receive `{:error, :unauthorized}` or `{:error, :unknown_channel}`; the gateway logs a warning and acknowledges silently, with no agent invocation.

3. **Build the reply path.** A `reply_fn` closure is built from the channel adapter and message target, so every later step — including the media ingest and the empty-message check below — can answer the sender.

4. **Ingest media.** If the message carries an audio or voice-note attachment, the shared transcription hook transcribes it to text — with or without a caption. When a caption accompanies the audio, the caption is kept and the transcript follows under a `[voice note transcript]` delimiter, so the agent sees both. A failed transcription answers the sender with a specific, actionable reply and schedules no turn: no backend configured gets "run `fermix setup`"; audio over the size cap (`[fermix_core.transcription] max_file_mb`, default 20) gets the limit and "send a shorter clip"; any other failure gets "the transcription failed, please try again". If the message carries one or more images on a media-capable channel, the gateway downloads them and attaches them as image content (not placeholders) so the agent sees the pictures directly. A failed download fails the turn loudly — there is no degraded image-less turn — and an image routed to a non-vision model fails loud rather than dropping silently; unlike transcription, a failed image download is log-only, with no reply to the sender. Multi-image messages are coalesced into one turn: Telegram albums (separate updates sharing a `media_group_id`) and WhatsApp per-image webhook messages are buffered by a short debounce (a brief wait to let the related parts arrive) and merged; Discord, Slack, and Signal already deliver all attachments in one message.

5. **Reject empty messages.** A message with no text and no readable media (a sticker, a poll, a blank — an image without a caption still counts as readable) gets the reply "Your message looks empty — send some text or media I can read." and never schedules a turn, so the queue stays free and no model is called. This runs after authorization, so an unauthorized sender is dropped earlier and never receives this reply.

6. **Prepare typing and streaming.** A `typing_fn` is built if the adapter exports `start_typing/1`. A `stream_spec` is resolved once per turn if the channel and config support streaming.

7. **Dispatch slash commands.** `Commands.parse/2` checks for a leading `/` command. Recognized commands run their own handler. A command that needs an agent turn (such as `/ultra`) returns `{:enqueue, modified_message}`; everything else — including an unrecognized `/command` — passes through to the agent as ordinary text.

8. **Enqueue.** The authorized message, with `source_trust` attached, is handed to `Gateway.Queue`, which runs one FIFO turn per conversation key (`{channel, chat_id, thread_scope}`). Multiple concurrent messages for the same conversation wait behind the active turn; different conversations run independently. If the agent process happens to be mid-restart at this moment, the sender gets "I'm restarting — please send your message again in a moment." instead of a silent drop.

`FermixChannels.Dispatcher` is a thin compatibility alias for `Gateway.ingest/2`.

## Two-tier trust model

Every authorized sender receives one of two trust levels. The capability registry (Fermix's internal catalog of tools the agent can use) uses this to filter which tools the agent may call.

| Trust level | Who gets it | Default tool surface |
|---|---|---|
| `:operator` | `owner_user_id` on a remote channel; local `cli` and `daemon` channels | Full registry: read-only, read-write, exec, network, external API, and — when computer use is enabled — GUI control (`:gui_control`) |
| `:guest` | `allowed_user_ids` / `allowed_sender_ids` entries who are not the owner | Read-only chat: no file or git writes, exec, network, external APIs, MCP tools, skills, or computer control |

Local channels (`cli`, `daemon`) are always operator-equivalent. They are loopback paths (connections that stay on your own machine and never touch the network) where the human owner is the sender.

`:guest` trust is functional but most multi-user scenarios are not yet exposed in the product. It exists today to prevent an allowlisted friend from accidentally running exec, network, or computer-control tools.

GUI control (`:gui_control`) is the computer-use capability — letting the agent drive the desktop by screenshot and mouse/keyboard. It is experimental and off by default. Even for an operator it is the most restricted class: it registers only when computer use is enabled, and it is never delegated to a sub-agent. Guests never see it. See [Capabilities and tools](/docs/capabilities-and-tools) for the full picture.

## Authorization rules

`Gateway.Authorizer.resolve/1` applies these rules in order:

1. If the channel string is `"cli"` or `"daemon"` (a local channel): operator.
2. If the channel string maps to no known channel key: `{:error, :unknown_channel}`.
3. If `sender_id` is absent: `{:error, :unauthorized}`.
4. If `sender_id` matches `Config.channel_explicit_owner_user_id(channel_key)`: operator.
5. If `sender_id` is in `Config.channel_ingress_user_ids(channel_key)`: guest.
6. Otherwise: `{:error, :unauthorized}`.

Denials are logged at warning level and silently acknowledged to the platform.

## Owner model

Each channel has two distinct owner lookups:

| Accessor | Behavior | Used by |
|---|---|---|
| `channel_explicit_owner_user_id/1` | Returns `owner_user_id` only. Returns `nil` if the key is absent. | Ingress gateway (trust resolution) |
| `channel_command_owner_user_id/1` | Returns `owner_user_id`, or falls back to the sole `allowed_user_ids` / `allowed_sender_ids` entry if `owner_user_id` is absent. | Slash command authorization |

This split is intentional. Adding a friend to `allowed_user_ids` gives them `:guest` trust and lets them chat with the agent. It does not promote them to operator. Operator status requires an explicit `owner_user_id` entry.

For slash commands, the fallback allows a single-user channel configured without `owner_user_id` to still authorize commands via the lone allowlist entry. Once there are two or more allowed users, an explicit `owner_user_id` or `command_allowlist` is required.

## Ingress allowlist config keys

The allowlist (the list of senders permitted to reach the agent) key name varies by platform:

| Channel | Ingress allowlist key | Owner key |
|---|---|---|
| Telegram | `allowed_user_ids` | `owner_user_id` |
| WhatsApp | `allowed_sender_ids` | `owner_user_id` |
| Discord | `allowed_user_ids` | `owner_user_id` |
| Slack | `allowed_user_ids` | `owner_user_id` |
| Signal | `allowed_sender_ids` | `owner_user_id` |

Example `~/.fermix/config.toml` stanza:

```toml
[fermix_channels.telegram]
owner_user_id = "123456789"
allowed_user_ids = ["987654321"]
command_allowlist = ["987654321"]
```

`command_allowlist` is a separate list that permits non-owner users to run mutating slash commands. Omitting it restricts slash commands to the owner.

Use `/whoami` from a channel account to discover the sender id, then add it with `fermix setup --reconfigure` or by editing `~/.fermix/config.toml` directly.

## Fail-closed behavior

An enabled channel that has no `owner_user_id` and no ingress allowlist has zero authorized senders. Every inbound message resolves to `{:error, :unauthorized}`. The gateway logs a warning and acknowledges silently. No agent turn runs.

This is intentional. Fermix does not fail open. A misconfigured channel is inert, not permissive.

`fermix doctor` reads the same config accessors and reports each enabled channel as either configured (owner present) or misconfigured (enabled without owner or allowlist, meaning every message will be denied).

## How trust flows to tools

Once a message passes authorization, `source_trust` is attached to the agent message and propagates through the entire turn:

```
Gateway.ingest
  → Authorizer.resolve → {:ok, %Authorization{trust: :operator | :guest}}
  → message.source_trust = authorization.trust

Gateway.Queue → MainAgent.handle_cast({:handle_message, msg})
  → AgentLoop with trust: msg.source_trust

AgentLoop.build_state
  → CapabilityRegistry.list_for(trust: trust)
      :operator → full capability set
      :guest    → read-only capability set
      nil       → read-only (safe fallback)
```

The same `source_trust` field is passed to `Memory.Admission` after a turn completes. Memory candidates in the `directive` category (behavior-shaping memory, formerly split as `instruction`/`correction`) from `:guest` sources are not promoted into the durable prompt context.

Trust also shapes the filesystem sandbox. In the default `standard` sandbox mode, the **request cwd** — the directory you ran `fermix ask` from, attached only by the local CLI — is admitted as an extra sandbox root, but only when the turn's `source_trust` is `:operator`, and only when it is a real directory strictly inside your real home directory. A guest turn never contributes a request cwd: it sees the roots the mode grants on its own (the workspace, plus the directory the daemon itself was launched from when that is inside your home) plus any explicit `[sandbox] allowed_roots` grants. See [sandbox and permissions](/docs/sandbox) for the full root model.

Operator trust also unlocks the sandbox approval loop. On an operator turn the gateway attaches an approval seam, letting the agent ask for access to a directory the sandbox denies via the `request_directory_access` tool — advertised only inside an attended operator conversation (one with both a live reply path and that seam, at the top level of the turn), so guest, scheduled, unattended, and delegated sub-agent runs never see it. The owner is shown the canonical path, the reason, and the config diff, and approves with `/confirm TOKEN`: single-use, 60 seconds, and bound to the originating channel, chat, thread, and user. Like `/grant` and `/revoke`, `/confirm` takes the strict operator gate — it never consults `command_allowlist`, so a guest trusted with other slash commands still cannot approve a grant. On approval the grant persists to `[sandbox] allowed_roots`, and on a chat channel the original request auto-resumes.

On Telegram and Discord the prompt also carries a one-tap **Approve** button; it sends the identical confirmation and rides the same single-use, time-limited, origin-bound, owner-only path. Every other surface shows the same prompt as text. In a shared chat on a button channel the code itself is deliberately withheld — approve from the button, a direct message, or the CLI.

A tap from anyone but the owner is refused before the token is spent: Discord answers the tapper with a private "you're not authorized to approve this" note and never ingests the confirmation at all, so the owner's pending approval survives untouched. `/confirm` also checks the token before it consumes it — a confirmation from the wrong channel, chat, thread, or user, or one that arrives after the 60 seconds, is rejected without burning the owner's live token.

## Idempotency

Idempotency means handling the same message twice has the same effect as handling it once: a duplicate is ignored, not acted on again. Webhook platforms (WhatsApp, Slack) retry delivery on slow acknowledgements. To prevent double-processing, the gateway maintains an in-memory idempotency cache keyed on `{channel, platform_message_id}`.

- Check-and-set is atomic: concurrent callers with the same key see exactly one `:fresh` and the rest `:duplicate`.
- Cache entries expire after 24 hours.
- If a dispatch task fails to start after the key is recorded, the key is deleted so the platform's retry can succeed.
- The cache is process-local. A daemon (the background Fermix service) restart clears it, which is acceptable because platform retry windows are measured in minutes, not hours.

Telegram uses long polling (it repeatedly asks the platform for new messages rather than being pushed them) and does not retry, so idempotency is not applied to that path.

## Related pages

- [Channels](/docs/channels): per-platform adapter behavior, transport modes, and attachment handling.
- [Configuration](/docs/configuration): full `config.toml` reference including channel stanzas, allowlist keys, and environment variable overrides.
- [The agent loop](/docs/the-agent-loop): how `MainAgent` and `AgentLoop` process an authorized turn.
- [Capabilities and tools](/docs/capabilities-and-tools): how trust level maps to the capability registry surface.
