Shoal Messages and Shoal Push: data flow and data map
Draft. This document has not completed legal review and is not yet in force.
DRAFT, 30 September 2026. Engineering description of what the hosted service processes, where, and for how long, as deployed. It is the factual basis for the privacy policy draft and for legal review; it is not itself legal advice. Every retention value here is set in the deployment configuration.
Our trust model distinguishes five things. They are the colour classes in the diagram and the sections below.
- Matrix-native E2EE: conversations between Matrix users in encrypted rooms. Content is encrypted on the phone; we cannot read it.
- Bridged conversations: Signal and Telegram chats. The bridge is a participant in the external protocol and necessarily handles plaintext.
- Retained metadata: what the homeserver, bridges and push server keep to function (who, which room, when), even when content is encrypted.
- Bridge credentials and sessions: what lets the bridge act as the subscriber on Signal or Telegram.
- Operational logs: service logs, metrics and backups.
Diagram
flowchart LR
classDef e2ee fill:#d8f3dc,stroke:#2d6a4f,color:#081c15
classDef bridged fill:#ffe5d9,stroke:#9d0208,color:#370617
classDef meta fill:#e0e1dd,stroke:#415a77,color:#0d1b2a
classDef cred fill:#fff3b0,stroke:#9c6644,color:#3d2b1f
classDef ops fill:#e7c6ff,stroke:#5a189a,color:#240046
subgraph Phone["Subscriber's phone (Sailfish OS)"]
App["Shoal Messages<br/>local store encrypted with a key from Sailfish Secrets"]
Dist["Shoal Push distributor"]
end
subgraph Cell["Shoal cell (EU VPS)"]
Caddy["Caddy (TLS termination, no access log)"]
HS["Synapse homeserver"]
HSDB[("Synapse DB:<br/>accounts, devices, room state,<br/>encrypted events, pushers, IPs 3 d")]
Media[("Media store<br/>30 d after last access")]
SB["mautrix-signal"]
TB["mautrix-telegram"]
BDB[("Bridge DBs:<br/>remote sessions and keys,<br/>contacts, id mappings")]
Push["ntfy (push)"]
PDB[("ntfy DB:<br/>user, token, ACL,<br/>undelivered pushes 12 h")]
Lic["Licence service<br/>(licence id, plan, expiry)"]
Logs["Logs: warnings/errors only,<br/>3 x 10 MB ring per service"]
end
Backup[("Encrypted backups<br/>7 days, EU storage")]
Signal["Signal servers"]
Telegram["Telegram servers"]
Fed["Other Matrix homeservers"]
MoR["Merchant of record<br/>(payment data)"]
App -->|"E2EE Matrix events (ciphertext)"| Caddy --> HS
HS --> HSDB
HS --> Media
HS -->|"E2EE events to/from other servers"| Fed
App -->|"end-to-bridge encrypted events"| HS
HS <-->|"appservice API"| SB
HS <-->|"appservice API"| TB
SB -->|"plaintext in memory,<br/>Signal protocol E2EE on the wire"| Signal
TB -->|"plaintext in memory,<br/>MTProto to Telegram (cloud chats)"| Telegram
SB --> BDB
TB --> BDB
HS -->|"push: room id, event id, counts only"| Push
Push --> PDB
Push -->|"token-authenticated stream"| Dist --> App
MoR -->|"webhook: licence id, plan, paid-until"| Lic
App -->|"licence id (token refresh)"| Lic
Cell -.->|"nightly, encrypted to an offline key"| Backup
class App,Fed e2ee
class SB,TB,Signal,Telegram bridged
class HSDB,Media,PDB,Lic meta
class BDB cred
class Logs,Backup ops
Diagram source (Mermaid). The site shows diagrams as text so it needs no scripts.
1. Matrix-native end-to-end encrypted conversations
- Content (text, files, reactions, edits) is Megolm-encrypted on the phone by matrix-rust-sdk. The homeserver stores and forwards ciphertext. Encrypted attachments are uploaded encrypted; the key travels inside the encrypted event.
- New private chats and DMs on our server are encrypted by default (
encryption_enabled_by_default_for_room_type: invite). Public or unencrypted rooms a subscriber joins are not; their content is readable by the server while retained. - Key backup (if the subscriber enables it) stores keys encrypted with the subscriber's recovery key; we cannot decrypt it.
- Federation: rooms with users on other homeservers are replicated to those servers, which apply their own retention. Bridged portal rooms are never federated (
matrix.federate_rooms: false).
2. Bridged conversations (Signal, Telegram)
- Between phone and bridge: portal rooms are end-to-bridge encrypted, so the homeserver database and backups hold ciphertext, not bridged message text. The bridge holds the room keys to do its job.
- Inside the bridge: the bridge decrypts every message it relays and handles it in plaintext in memory. Anyone who controls the bridge process (an attacker on the host, or us) could read bridged messages as they pass. This is the difference from Matrix-native E2EE, and it must be stated plainly to subscribers.
- Signal side: the bridge is a Signal linked device of the subscriber's account; traffic to Signal is Signal-protocol encrypted, as for any Signal client.
- Telegram side: the bridge is a Telegram client session. Telegram cloud chats are encrypted client-to-server only (Telegram can read them, as with any Telegram client). Telegram secret chats are device-specific and are not bridged.
- What the bridge stores (in its PostgreSQL database): per subscriber, the remote session (below), the mapping between remote message ids and Matrix event ids, portal and ghost-user records (contact and group names, avatars), disappearing-message timers, and Megolm keys for portal rooms (encrypted with the bridge's pickle key, ratcheted and deleted as used). No message bodies are stored by design against the bridge schema: mautrix-signal has
signalmeow_backup_*tables used for Signal history transfer during backfill, which we disable. - History: no import of old remote history (
backfill.enabled: false). - View-once: Telegram view-once media is not bridged; Signal view-once messages are redacted after viewing.
3. Retained metadata
| Data | Where | Retention |
|---|---|---|
| Account: Matrix id (random by default), password hash, display name, licence id link | Synapse | Until deprovisioning; erased on deactivation |
| Devices, access tokens, E2EE public keys | Synapse | Until logout; unused devices after 90 days |
| Room membership and state (room names, members, including bridged contacts' display names and avatars) | Synapse | Life of the room; state is not purged by message retention |
| Event metadata (sender, room, timestamp, size, type) and ciphertext | Synapse | 30 days default (room policy 1 to 90 days) |
| Media (encrypted in E2EE rooms; avatars in clear) | Synapse media store | 30 days after last access; remote 7 days |
| Client IP and user agent per device | Synapse | 3 days |
| Pushers (pushkey = the subscriber's ntfy topic URL) | Synapse | Until the device removes it |
| Push messages (room id, event id, unread count) | ntfy | Delivered immediately; undelivered held 12 hours |
| ntfy user, token, topic prefix | ntfy | Until deprovisioning |
| Licence id, app, plan, expiry, merchant subscription and transaction references | Licence service | Life of the licence plus accounting retention with counsel |
| Payment and billing identity | Merchant of record (not us) | Merchant's policy |
4. Bridge credentials and sessions
| Bridge | What is held | Visible to the subscriber as | How it ends |
|---|---|---|---|
| Signal | Linked-device identity keys, sessions, pre-keys, profile keys, group state, recipient records (Signal ids, phone numbers) | "Shoal Messages (hosted bridge)" under Signal Settings, Linked devices | Unlink in Signal; logout from the bridge; deprovisioning; Signal unlinks devices after prolonged inactivity |
| Telegram | Authorised session (auth key), cached access hashes, usernames and phone numbers of contacts seen | "Shoal Messages (hosted bridge)" under Telegram Settings, Devices | Terminate the session in Telegram; logout; deprovisioning |
- Stored in the bridge's own database, reachable only from the cell's internal network; included in the encrypted backups.
- Telegram two-step-verification passwords are used for login and not stored by the bridge against mautrix-telegram's login code.
- Deprovisioning logs out every session: the remote side revokes the linked device or session, and the bridge deletes the subscriber's portals.
- Open item: at-rest encryption of the VPS disk (Hetzner cloud volumes are not encrypted by default). Candidate: LUKS on the data volume with the key entered at boot.
5. Operational logs, metrics and backups
- Logs: every service logs warnings and errors only; request/access logs (IPs, paths, user ids per request) are disabled in Synapse, Caddy, ntfy and PostgreSQL. Error lines can contain Matrix user ids and room ids. Kept in a 3 x 10 MB ring per container on the host; never shipped elsewhere.
- Metrics: Prometheus counters (request rates, latencies, resource use), 15 days, no per-user labels.
- Backups: nightly, encrypted to an offline key before leaving the process (age), 7 days, mirrored to EU storage of the same provider in a different location. Backups contain everything in sections 3 and 4 and the ciphertext of section 1 and 2 events; restoring one can resurrect data deleted within the last 7 days, which the privacy policy must say.
Subprocessors and third parties (draft list)
| Party | Role | Data |
|---|---|---|
| VPS provider (Hetzner, Germany/Finland) | Hosting, backup storage | Everything above, encrypted backups |
| Signal Messenger LLC (US) | The subscriber's own Signal service | What any Signal client sends |
| Telegram (Telegram FZ-LLC / Telegram Messenger Inc.) entity | The subscriber's own Telegram service | What any Telegram client sends |
| Merchant of record (Paddle or Lemon Squeezy, undecided) | Payment, VAT | Billing identity, payment data |
| Other Matrix homeservers | Federation, when the subscriber joins their rooms | Room events for those rooms |
Source: services/privacy/data-flow.md in the Shipwright repository; paths and ADR numbers in the text refer to it.