Skip to content

RFC: optional native backend (presage) behind a Cargo feature, to drop the signal-cli / JVM dependency #635

Open

Description

@johnsideserf

RFC: optional native backend (presage) behind a Cargo feature, to drop the signal-cli / JVM dependency

Labels: enhancement · RFC · help wanted
Status: proposal / discussion

Summary

Add a second, opt-in messaging backend built on presage (→ libsignal-service-rs → Signal's official Rust libsignal), selectable at compile time via a Cargo feature. The existing signal-cli backend stays the default while the native one is proven out. The end goal is a pure-Rust siggy with no Java runtime and no external signal-cli process at runtime — at which point native becomes the default, with signal-cli retained as a supported fallback rather than deleted on a schedule. Full removal of signal-cli is a separate, later decision gated on presage's track record in siggy specifically.

This is a migration architecture, not a commitment to maintain two co-equal backends at verified parity forever, and explicitly not a fork.

Prior art: gurk-rs is a Rust TUI Signal client built directly on presage — 25 releases over several years, packaged in Arch/Nix/FreeBSD. This proposal's architecture differs (two backends behind one trait, migration path), but gurk proves the presage-in-a-TUI foundation works, and serves as reference reading when stuck on a presage call.

Motivation

  • The signal-cli dependency is the single biggest install/operational friction point: a separate Java application users must fetch, a setup wizard that has to locate it, and JSON-RPC-over-stdio plumbing plus subprocess lifecycle management around it.
  • Dropping the JVM is on-brand for a tool whose pitch is "lightweight, no Electron, just your terminal."
  • We're already in Rust, and presage is close to purpose-built for our setup: designed around the linked secondary-device flow we already use, with a SQLite store (presage-store-sqlite) that aligns with our persistence model.

Background: current architecture

Keyboard → InputAction → App state → SignalClient (mpsc) → signal-cli (JSON-RPC stdin/stdout)
signal-cli → JsonRpcResponse → SignalEvent (mpsc) → App state → SQLite + Ratatui render

The SignalClient boundary is the seam this whole proposal hangs off. The rest of the app talks to it via our own InputAction / SignalEvent types over mpsc channels and does not need to know which backend is behind it.

Proposal

  1. Formalize SignalClient as a backend-agnostic trait (it nearly is already).
  2. Provide two implementations selected by mutually exclusive Cargo features:
    • signal-cli-backend (default) — current behaviour, unchanged.
    • native-backend — presage, in-process.
  3. Map presage onto the existing actor shape: presage's receive stream runs in a Tokio task feeding the SignalEvent channel; InputActions translate into presage Manager calls on the command side.
  4. Keep signal-cli the documented default until native reaches parity on core paths, then flip the default. signal-cli remains supported post-flip.

Design principles / constraints

The trait is an anti-corruption layer, not a mirror

The SignalClient trait is the only module that knows presage (or signal-cli) exists. No backend type or error leaks past it into app state — everything crosses as our own SignalEvent / InputAction / error types. Where one backend can do something the other can't, that's a capability flag or optional method, never a leak. Two consequences worth stating explicitly, because they're where this goes wrong:

  • Shape the trait around siggy's domain needs, not presage's Manager API. Shaped around presage, the signal-cli adapter turns awkward and we've half-mirrored presage anyway. Both backends adapt inward to an interface defined by what siggy needs.
  • Do not mirror presage's architecture across the app. The tempting response to presage's churn is to mirror their structure so upstream changes map over trivially — resist it. Mirroring couples the whole app to presage's shape, so every upstream refactor propagates everywhere (maximum blast radius). Instead, quarantine presage's shape inside the adapter: within that one module, staying close to their mental model makes porting their diffs mechanical; outside it, the app never moves when they refactor.

Acknowledgment vs. persistence — the data-loss contract (must be settled in the trait design)

With signal-cli, the daemon receives envelopes, acks them to the server, and holds messages until siggy consumes them over JSON-RPC. In-process, siggy becomes the thing that acks — and Signal's server will not redeliver an acked envelope, nor is there any fetch-history escape hatch. If presage acks and siggy crashes before the message hits SQLite, that message is gone forever.

The trait contract must therefore pin down where acknowledgment happens relative to persistence: either ack only after durable write, or accept at-least-once delivery and deduplicate. This is invisible in happy-path testing and catastrophic in the field; it is a Phase 1 design decision, not a Phase 4 discovery. (Verify what control presage's Manager actually gives us over ack timing — open question below.)

Message identity must be normalized in the trait vocabulary

signal-cli's JSON-RPC and presage represent timestamps, UUIDs/ACIs, and sender identifiers differently. siggy's SQLite needs one normalized message-key scheme defined in our event types, or cross-backend message history (e.g. a user who re-links from one backend to the other) gets inconsistent. Decide the canonical scheme in Phase 1.

Backend state is not portable

signal-cli and presage are separate device registrations on the Signal account, with separate identity keypairs and ratchet/session state in separate stores. Switching backend = re-link as a new linked device (scan the QR again). Two Signal-side constraints follow:

  • Accounts have a linked-device cap (~5 slots). Backend-hopping burns slots and leaves zombie registrations to clean up from the phone.
  • Inactive linked devices are unlinked after ~30 days. A dormant registration is not a hot standby.

We will not attempt a state importer between the two stores — it would couple to both projects' internals and rot fast. "Two flavours" is two build/install choices, not a runtime toggle.

Mutually exclusive Cargo features — a deliberate convention violation

Enabling both backends, or neither, is a loud compile_error!, not a silent pick. Rationale: the dependency trees are wildly different — a native build must not compile any JSON-RPC/subprocess code, and a signal-cli build must not pull presage's heavy tree.

This deliberately violates Cargo's features-are-additive convention, which breaks cargo build --all-features, feature unification in any future workspace, and default docs.rs builds. We accept that tradeoff for the tree-weight win, but CI recipes and CONTRIBUTING must document the correct incantations (--no-default-features --features native-backend, etc.), and docs.rs metadata should pin a working feature set.

Security posture note

In-process means siggy's own memory and store hold live Signal identity keys and session state, where today those live in signal-cli's process and data dir. This is not materially worse (signal-cli's store is also on-disk under the user's account), but it changes what a bug in siggy can leak. Worth one line in the docs; store file permissions should match what presage-store-sqlite defaults to or tighter.

Two distinct upstream risks — different problems, different fixes

Conflating these makes the venture look scarier than it is. Keep them separate:

1. Server treadmill (runtime break). Signal changes something server-side; presage must catch up; if it lags, native breaks at runtime. This is not new — signal-cli chases the same server (official clients expire roughly every three months, after which the server can make incompatible changes). Native changes who we depend on to be fast (whisperfish's smaller team vs signal-cli's very responsive maintainer) and how much buffer we have. Budget for the lumpy tail: usually a quiet dependency bump, occasionally an unpredictable bad week.

Mitigation: signal-cli as a supported fallback. But be honest about the two levels this operates on:

  • Project level — strong. A presage lag never takes siggy down for default users, and native users have a documented escape path. siggy-the-project survives.
  • Individual user level — weak. A native-build user falling back must install a JVM + signal-cli (the thing they chose native to avoid), switch builds, and re-link, because their dormant signal-cli registration — if they ever had one — was unlinked after 30 days of inactivity. Falling back is a bad day, not a config flip. The docs must not oversell this.

2. presage API instability (compile/integration break). presage offers no semver stability; a version bump can move types and signatures and break our integration code. Mitigation: the anti-corruption layer above, plus dependency discipline — pin presage to a specific commit, never track HEAD, bump deliberately while reading their diff. Between bumps, nothing breaks: API churn only arrives when we invite it. Churn becomes scheduled maintenance contained to one adapter module.

Evidence this is manageable: gurk has shipped on exactly this stack for years without dying overnight. The snakes are real; they get milked on a regular cadence.

Testing regime (three tiers)

There is no Signal sandbox and no official test harness; every signal-cli-wrapping client already lives with this, including siggy today — the native backend doesn't create the problem, it adds one more surface to an existing regime. The regime:

Tier 1 — per-commit, automated: mocked unit tests. All app logic (UI, state, storage, rendering) tests against a mocked SignalClient. The trait boundary makes these mocks clean. Both backends' adapters get unit tests for their mapping logic (event translation, error translation, identity normalization). This is the only tier that gates merges.

Tier 2 — nightly / pre-release, automated: cross-backend canary lane. A headless harness — driving the trait directly, not puppeting the TUI — runs two instances with different backends: native sends → signal-cli receives, then reverse. signal-cli acts as the trusted oracle, giving interop coverage and avoiding the bootstrap problem where both ends share the same bug. Covers happy paths: send/receive 1:1, and with more rig work, groups and attachments. Constraints that don't yield to cleverness:

  • Setup: two test accounts registered manually once (SMS + captcha are automation-hostile; Signal actively fights virtual numbers — don't automate registration). CI instances link as secondary devices; the protocol stores persist between runs as cached artifacts. Regular runs conveniently keep the linked devices active, sidestepping the 30-day unlink.
  • Serialization: protocol stores are stateful and single-writer. Two concurrent runs on the same registration will fork the ratchet and desync sessions — concurrency group of one, always.
  • Not a merge gate: it hits Signal's production servers, so some red runs are their weather, not our code; fork PRs can't see the secrets anyway. It notifies, it doesn't block.
  • ToS reality: low-volume canary traffic between two consenting test accounts is the mildest case, but automated unofficial clients are what Signal's abuse systems watch for. Keep volume tiny; expect the occasional captcha-shaped interruption needing a human, including occasional manual re-links when the state cache corrupts.
  • Sequencing: this rig is a third thing to maintain, on the same protocol treadmill as the product. Build it in Phase 4, once native basically works — not as scaffolding first. If it flakes more than it catches, demote it without guilt; Tier 3 remains the floor.

Tier 3 — pre-release, manual: parity checklist residue. A written protocol (two test accounts, one phone, ~10 minutes) covering what automation can't judge: trust-change flows, rendering, edge semantics. Run before releases that touch the messaging layer. Parity-table rows record "last verified in v0.x" rather than pretending continuous assurance — scoped to what a solo maintainer plus daily dogfooding can actually deliver. Daily driving is the strongest continuous test this class of software has.

Maintenance automation (agent-assisted bumps — draft, don't auto-fix)

The presage churn cost can be largely automated, with one hard rule: the agent drafts, a human verifies and merges. Auto-merging changes to the one layer whose failure mode is invisible message loss would automate the cheap 80% and rubber-stamp the load-bearing 20% — compiling was never the hard part; knowing it still delivers messages is.

  • Upstream watch: a scheduled job diffs presage / libsignal-service-rs against our pinned commit and produces a summary of what changed and whether it touches surfaces our adapter uses. Kills the "surprise refactor" problem — we find out on our schedule, with a briefing.
  • Agent-drafted bump PRs: agent updates the pin, ports the adapter, gets it compiling and green on Tier 1, and opens a PR summarizing what changed and which parity-table rows are affected — pre-scoping the Tier 3 checklist for that release to minutes.
  • Failure is a first-class outcome: if upstream's change is architectural rather than mechanical, the right PR is "here's what changed, here's why the mechanical port doesn't work, here are options" — not a heroic 400-line guess. The agent is a scout, not a hero.
  • Human runs the checklist (and/or the Tier 2 lane), human merges. That's the entire safety property.

Net effect: the lumpy-tail risk converts from unpredictable weekend bisecting a refactor into reviewing a pre-written PR with a briefing attached.

Phased plan

  • Phase 0 — spike. Behind native-backend: link as secondary device, send one 1:1 message, receive one 1:1 message. No UI polish. Goal: confirm the actor mapping; shake out build/dependency issues (protoc, MSRV, patch behaviour); sanity-check ack-timing control in presage's API.
  • Phase 1 — trait (highest regression risk; own guardrail). Lock down the backend-agnostic SignalClient trait — including the ack/persistence contract and normalized message identity — and refactor the working signal-cli backend behind it. This step can break existing users before native provides any value, so: characterize current behaviour first (even crude snapshot tests of the JSON-RPC event mapping), refactor under test, and release the refactor alone to soak before native lands on top. Add feature gates + compile_error! guard.
  • Phase 2 — core paths. Native: link, send/receive 1:1, sync messages from phone, contact/profile name resolution.
  • Phase 3 — breadth. Groups, attachments (send + receive), reactions, replies/quotes, typing indicators, receipts, deletes/edits, disappearing timers — everything siggy ships today.
  • Phase 4 — soak + tooling. Parity-table burndown, dogfood, early-adopter feedback; build the Tier 2 canary lane and the upstream-watch automation.
  • Phase 5 — flip. Native becomes default. signal-cli retained as a supported fallback (documented, still tested), not deprecated. Full removal is a separate later decision.

Parity table (seed — the burndown list; the bar is everything siggy ships today)

Capability signal-cli (current) native (presage) Notes
Link as secondary device ✅ ? presage's primary flow; expected easy win
Send / receive 1:1 ✅ ?
Message sync from phone ✅ ?
Contact / profile name resolution ✅ ?
Group send/receive ✅ ? group v2 = likely divergence point
Group admin ops (v2) ✅ ? seeded early; high bug-report risk
Attachments (send / receive) ✅ ? check download-dir semantics vs current
Reactions ✅ ? shipped today — must not regress silently
Replies / quotes ✅ ? shipped today
Message edits ✅ ? confirm current siggy support level first
Remote delete ✅ ?
Disappearing-message timers ✅ ?
Typing indicators ✅ ? confirm presage support
Read receipts ✅ ?
Trust / identity handling ✅ ? seeded early; high bug-report risk
PNI handling / usernames ? ? audit what siggy does today; newer protocol surface

Trust/identity and group v2 are where the backends will most visibly diverge — they generate the most "works on signal-cli, broken on native" reports. Rows record "last verified in vX.Y", not a permanent checkmark.

Licensing — the native build is AGPL-3.0, full stop (verified; direction decided)

The entire native stack is AGPL-3.0: presage, libsignal-service-rs, and Signal's own libsignal crypto core (AGPL-3.0-only) — which any native path must link, since we never reimplement crypto. AGPL is strong copyleft: the combined native binary must be conveyed under AGPL-3.0. There is no permissive Signal-protocol library to swap in, and shipping the native build as MIT/Apache is not legally available (and would be the actual licence violation).

Decision: relicense the whole repo AGPL-3.0-only (from GPL-3.0). GPL-3.0 → AGPL-3.0 is an explicitly permitted combination (GPLv3 §13 ↔ AGPLv3 §13) and the tightening direction is the uncontroversial one; a courtesy heads-up to past contributors in the relicense PR is good form. This matches gurk and the entire upstream stack. Mechanics: swap LICENSE, set license = "AGPL-3.0-only" in Cargo.toml (exact SPDX id; AGPL-3.0 is deprecated), fix the README, credit whisperfish + Signal. AGPL's only addition over GPL is the network-source clause, which a local TUI essentially never triggers. (Not legal advice.)

Distribution — crates.io cannot carry the native build (verified constraint)

The Signal presage is git-only (the presage crates.io name is taken by an unrelated CQRS crate), and its whole tree — libsignal-service-rs, libsignal-protocol, zkgroup — is pulled from git. crates.io refuses to publish any crate declaring a git or path dependency, optional or not. A workspace split doesn't help (a path dep on an unpublished crate is equally unpublishable). gurk's README confirms the same constraint bites them.

Consequences to be honest about in docs:

  • cargo install siggy (crates.io) = signal-cli build only for now. Native ships via install script / GitHub release binaries / cargo install --git / build-from-source — this defers part of the "single cargo install, no Java" win.
  • "No dependencies" is a runtime claim, not a build-time one. Building native from source requires protoc (and perl), per the libsignal tree — gurk documents the same. Ensure the default build does not pull protoc, or casual-contributor DX breaks for a feature they didn't enable.
  • Longer term: vendor presage, or help upstream toward crates.io publication.

Open questions / verify in Phase 0–1

  1. Ack timing control: what does presage's Manager/receive API actually let us control about envelope acknowledgment relative to our persistence? Determines which side of the ack/persistence contract is implementable.
  2. [patch.crates-io] behaviour: the curve25519-dalek git patch is workspace-global and can't be feature-gated (though it is not itself a publish blocker — patch sections are stripped from published manifests). Confirm no "unused patch" warning or --locked CI failure in the signal-cli-only build where the crate isn't in the graph.
  3. MSRV: libsignal pins a recent Rust (~1.89 last checked) vs siggy's 1.70+. Confirm exact version; decide per-feature MSRV gating in CI.
  4. presage feature coverage for: typing indicators, receipts, edits, remote delete, disappearing timers, the group v2 ops we expose. Drives the parity table.
  5. Windows: verify presage/libsignal build and run there before promising the current platform matrix for native.
  6. Binary size impact of the presage tree.
  7. CI matrix: add the native lane (--no-default-features --features native-backend) across ubuntu/macos/windows; native lane allowed-to-fail during Phases 0–2, required-green from Phase 3.

Non-goals

  • Not a fork — one repo, one issue tracker, one release.
  • Not permanent co-equal dual maintenance — but signal-cli is retained as a fallback past the default-flip; removal is a separate, later decision, not automatic at Phase 5.
  • Not reimplementing any cryptography — presage uses Signal's official libsignal core.
  • Not a state importer between backend stores.
  • Not continuous verified parity on every release — parity is verified per the three-tier regime, scoped to a solo maintainer plus dogfooding.
  • Not changing the default backend in this issue's scope (that's Phase 5, gated below).

Definition of "ready to flip default" (Phase 5 gate)

  • Parity on everything siggy ships today (full table, "last verified" current), with trust/identity and group v2 at parity or with documented, acceptable differences.
  • Ack/persistence contract implemented and torture-tested (kill -9 during receive; no lost messages).
  • A resolved, documented distribution story for the native build.
  • Tier 2 canary lane green over a soak period, with no class of silently-dropped messages.
  • Fallback path (native → signal-cli re-link) documented honestly, including JVM reinstall and device-slot implications.

Activity

  1. johnsideserf commented on Jul 7, 2026

    @johnsideserf
    OwnerAuthor

    This RFC now has an implementation plan and a tracked issue structure:

    Two of the RFC's open questions got resolved during planning with upstream evidence worth recording here:

    1. Ack timing (open question 1): resolved, negatively. libsignal-service acks each envelope on the websocket before yielding it to the consumer stream (MessagePipe::stream doc: "Envelopes yielded are acknowledged"). Caller-controlled ack is not available without forking. The plan therefore commits to the RFC's other branch — at-least-once delivery + dedup + persist-immediately — with the residual crash window measured in the spike and bounded by a torture test.
    2. Linking is currently broken upstream against current Signal mobile (Linking secondary device stopped working using latest Signal mobile app (v8.17) whisperfish/presage#419, fix in presage PR refactor: split handle_message into resolve/push/notify-policy (closes #415) #421) — the Phase 0 spike (feat(native): Phase 0 — spike: prove presage link/send/receive #639) gates on it.

    Also confirmed during planning: presage-store-sled is gone upstream (sqlite-only now), there's no evidence of Windows support anywhere in the presage ecosystem (plan keeps signal-cli as the Windows story), and presage has no group-admin API (capability-gated in Phase 3).

    Leaving this issue open as the RFC of record; it closes with the Phase 5 flip (#645).

  2. hoehermann commented on Jul 8, 2026

    @hoehermann

    You might also want to take a look at https://github.com/mautrix/signal/tree/main/pkg/signalmeow . It is written in go, but seems to implement more features of the Signal protocol.

  3. johnsideserf commented on Jul 15, 2026

    @johnsideserf
    OwnerAuthor

    Thanks for the pointer. signalmeow is impressive on feature breadth (it powers the mautrix bridge, which says a lot about its protocol coverage), and I did look at it while weighing options.

    The reason I landed on presage is the integration shape rather than feature count: siggy is a Rust binary on a tokio runtime, so a Go library means either a CGo/FFI boundary or shipping it as a sidecar process. The sidecar model is exactly what this RFC is trying to retire (that is signal-cli's model today, minus the JVM), and an FFI boundary across a GC runtime brings its own class of problems. presage keeps everything in-process, one language, one runtime.

    Since you commented, the Phase 0 spike actually completed: linking, receive, and send are proven against production Signal at presage's current HEAD, on Windows as well (findings in docs/solutions/architecture/native-backend-spike-findings.md, #639). So presage's biggest open risk, whether linking still works against current mobile clients, is retired.

    signalmeow stays useful here as a protocol reference though, especially for the Phase 3 breadth work (groups, attachments, receipts); where presage's behavior is ambiguous, a second independent implementation to compare against is valuable. Appreciate you taking the time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions