Skip to main content

Relay and persistence

Softchat owns relay frame validation and a serialized state machine. The low-level API lets your application own WebSocket execution; native consumers can instead select the optional managed WSS/TLS/Noise resource. On Android, SoftchatAccount owns both the standard managed relay path and durable publish intent, ingestion, delivery leases, and recovery in Rust SQLite.

Frames, filters, authentication, and batches

Operation contractrelay.wire

Validate relay frames, filters, authentication, and EVENTS batches.

Inputs and exact bounds
  • frame at most 512 KiB
  • at most 64 filters
  • at most 256 batch events
Output
Validated frames or ordered session actions.
Trust and authentication
Socket text is untrusted until Rust validation succeeds.
Stable errors
invalid_relay_frame, invalid_relay_filter, invalid_relay_authentication, invalid_relay_batch · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
parse_client_relay_frameparse_relay_response_frameRelayFilter

The wire API covers EVENT, REQ, CLOSE, OK, EOSE, CLOSED, NOTICE, AUTH, and COUNT, plus Softchat EVENTS batches. Filters retain exact prefix, time, limit, and generic tag semantics.

An EVENTS batch is flat, non-empty, bounded, and has unique event IDs. The relay returns one terminal OK per event, allowing partial acceptance. Create NIP-42 authentication with the exact relay URL and challenge; do not reuse it for another relay or time window.

let filter_json = format!(
r#"{{"kinds":[14],"#p":["{}"],"limit":100}}"#,
recipient.to_hex(),
);
let filter = RelayFilter::from_json(&filter_json)?;
let request = ClientRelayFrame::Req {
subscription_id: "chat".to_owned(),
filters: vec![filter],
}.to_json()?;
let checked_request = parse_client_relay_frame(&request)?;
let checked_response = parse_relay_response_frame(response_json)?;

let auth = plan_nip42_authentication(
&identity,
challenge,
"wss://relay.example",
created_at,
)?;

Native Rust exposes the direct frame/filter enums. ClientRelayFrame::Events encodes a bounded multi-event batch.

RelaySession lifecycle

Operation contractrelay.session

Drive a serialized transport-independent relay state machine.

Inputs and exact bounds
  • at most 128 subscriptions
  • at most 1,024 outbound frames
Output
Ordered host actions, socket-handoff records, and a diagnostic snapshot.
Trust and authentication
Actions request host work; they do not assert persistence or delivery.
Stable errors
invalid_relay_session, relay_session_queue_full · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
RelaySessionRelaySession::confirm_sentOutboundRelayFrame

The host serializes every call and executes returned actions in order:

connect -> open transport -> transportConnected -> authenticate if requested
-> authenticated -> send queued subscriptions and publishes
drain outbound -> socket write succeeds -> confirmSent(event IDs)
receive event -> persistEvent -> host transaction commits -> confirmIngested
transportLost -> persist delivery/retry state -> schedule reconnect
cancel -> close transport -> release session
let mut session = RelaySession::new();
let open = session.connect()?;
socket.open(open);
session.transport_connected()?;

for outbound in session.drain_outbound(256)? {
socket.send(&outbound.frame)?;
session.confirm_sent(&outbound.delivery_event_ids)?;
}

for action in session.receive(frame_json)? {
if action.kind == RelaySessionActionKind::PersistEvent {
let event = action.event.expect("persist actions contain an event");
let event_id = event.id.clone();
database.transaction(|tx| tx.ingest(event))?;
session.confirm_ingested(&event_id)?;
}
}

Publishing and delivery

Operation contractrelay.publish

Persist intent and queue bounded event batches for tracked delivery.

Inputs and exact bounds
  • 1 to 256 unique verified events per batch
  • at most 1,024 pending publishes
Output
Outbound frames and later delivery actions.
Trust and authentication
Sending bytes is not delivery; terminal relay acknowledgement drives state.
Stable errors
invalid_relay_batch, invalid_delivery_state, relay_session_queue_full · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
RelaySession::publishclassify_delivery_acknowledgement
  1. Commit the event and publish intent locally.
  2. Claim committed work and call publish with a bounded batch.
  3. Drain a frame, write it to the socket, then call confirmSent.
  4. Feed complete relay responses to receive.
  5. Commit every complete DELIVERY_CHANGED decision.

pending, inFlight, accepted, rejected, and retryable describe host delivery state. Draining is not a socket write, and a successful socket write is not relay acceptance. Branch on deliveryDecision.state and deliveryDecision.category; relay prose is diagnostic only.

database.persist_publish_intent(&event)?;
session.publish(vec![event])?;
for frame in session.drain_outbound(256)? {
socket.send(&frame.frame)?;
session.confirm_sent(&frame.delivery_event_ids)?;
}

Durable ingestion contract

Operation contractrelay.ingestion

Commit received verified events before acknowledging ingestion.

Inputs and exact bounds
  • at most 1,024 events awaiting host persistence
Output
A validated host ingestion transition.
Trust and authentication
Receiving bytes is not persistence; confirm only after an atomic durable commit.
Stable errors
invalid_persistence_result, invalid_relay_session · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
RelaySession::confirm_ingestedvalidate_ingestion_result

IngestionBatch contains verified events. Apply it in one authoritative transaction and return a total, disjoint IngestionResult: every event ID is accepted, already present, or rejected exactly once. The validator rejects an incomplete or overlapping result before session state advances. Android does not implement this adapter: SoftchatAccount.incoming.ingest owns the transaction and validates its receipt in Rust.

database.transaction(|tx| tx.ingest(batch.events()))?;
validate_ingestion_result(&batch, &result)?;
for event in batch.events() {
session.confirm_ingested(&event.id().to_hex())?;
}

Recovery

Operation contractrelay.recovery

Inspect diagnostics, rebuild from durable host intent, and cancel safely.

Inputs and exact bounds
  • at most 128 subscriptions, 1,024 outbound frames, 1,024 pending publishes, and 1,024 in-flight ingestions
Output
Redacted recovery state and close/reconnect actions.
Trust and authentication
The host remains authoritative for persisted intent and timers.
Stable errors
invalid_relay_session · handling and retry rules
Ownership, lifecycle, and execution rules
Ownership
Rust owns protocol semantics, Android account SQLite/use-case state, and optional native relay I/O; the host owns account lifecycle, HTTP/media/background effects, files, and UI.
Retry
Retry only host transport or explicitly retryable session work; validation and authentication failures are permanent for the same input.
Lifecycle
Language ownership is automatic; explicitly cancel or erase only at the operation-specific authority boundary.
Concurrency
Serialize mutable session capabilities; immutable values follow the language type system.
Cancellation
The host owns async cancellation; use cancel, shutdown, close, or erase only where exposed.
Exact public symbols
RelaySession::snapshotRelaySession::cancel

After transport loss, persist returned delivery changes, close the native socket, and schedule only the requested delay. snapshot() is diagnostic only. Recreate ephemeral platform resources after process death; restore subscriptions from account policy and publish intent from the authoritative store.

let actions = session.transport_lost()?;
log_redacted(session.snapshot());
execute_recovery(actions);