Architecture and boundaries
The integration rule is simple: Rust owns deterministic protocol semantics; the host owns effects. A platform facade converts between idiomatic values and the core without implementing a second cryptographic or protocol path.
| Concern | Softchat owns | Your application owns |
|---|---|---|
| Identity | validation, public-key derivation, signing, NIP-44 | secure storage, unlock policy, authority acquisition |
| Events | canonical IDs, signatures, raw tags, typed projections | non-Android storage policy and retention |
| Relay session | frame codecs, state transitions, action plans | WebSocket execution when using the low-level API |
| Managed native relay | WSS/TLS/Noise, timeouts, reconnect timing, action correlation, shutdown | platform trust initialization, reachability hints, account lifecycle |
| Android persistence | SQLite schema, migrations, transactions, projections, delivery recovery, queries | app-private path, account lifecycle, IO dispatch |
| Other persistence | bounded ingestion contracts and validation | transactions, migrations, durable storage |
| Noise | exact handshake/transport state and chunking | custom socket bytes, or the managed native relay |
| Android sync | fingerprint queries, reconciliation, ingestion, checkpoint | managed relay lifecycle or custom WebSocket execution |
| Other sync | bounded Negentropy snapshots and reconciliation | querying and changing the database |
| Attachments | metadata validation, streaming transform, NIP-98 plan | files, staging, HTTP, redirects, background transfer |
| Diagnostics | stable counters, redacted failures, build fingerprint | telemetry transport, sampling, user consent |
Trust transitionsโ
- Validate bytes or JSON before treating them as a protocol value.
- Authenticate every NIP-59 layer before exposing its rumor.
- Persist an inbound relay event in one authoritative transaction; Android
calls
SoftchatAccount.incoming.ingest. - Call
confirmIngestedonly after that transaction commits. - Stage decrypted media privately until
finishauthenticates its tag.
Receiving bytes is not persistence. Sending bytes is not delivery. A WebSocket callback must therefore never mark an event as safely stored or acknowledged without the corresponding state transition.
Data ownershipโ
- Identities, Noise transports, Negentropy clients, relay sessions, and attachment streams hold native state.
- Events, rumors, filters, projections, request plans, diagnostics, and encrypted messages are ordinary immutable language-owned values.
- Generated UniFFI/JNA, UniFFI Swift/C, and raw wasm-bindgen APIs are internal and may change without notice.
- Rust
- Android
- Swift
- TypeScript
LocalIdentity, RelaySession, NoiseTransport, and stream objects own
state. Use typed newtypes for keys and event identifiers at trust boundaries.
Keep one SoftchatAccount in the signed-in session and call it from a
serialized IO dispatcher. Rust owns its SQLite file and use-case queries.
Android retains WorkManager, HTTP/media networking, files, lifecycle, and
Compose. Prefer account.startManagedTransport(context) for relay networking;
use account.transport only when the host needs a custom socket policy. Never
open the account file through Room or retain generated JNA objects.
One actor should serialize a relay connection, timers, and its
RelaySession/NoiseTransport. Keep URLSession, GRDB, files, and background
tasks outside SoftchatKit.
Use a Worker for large synchronous operations. Keep WebSocket, fetch,
IndexedDB, service workers, and asset resolution in JavaScript.
Bounds and failure policyโ
Inputs are bounded before expensive work. The portable signed-event size limit is 512 KiB; timestamp, tag, batch, frame, Noise, reconciliation, plaintext, ciphertext, and attachment limits are enforced by the relevant constructor or operation. Treat a rejected limit as a permanent input failure, not a reason to retry with more memory.
Errors are stable and redacted. Never branch on display text or upstream exception names. See errors and diagnostics.