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
relay.wireValidate 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
- Rust
- Android
- Swift
- TypeScript
parse_client_relay_frameparse_relay_response_frameRelayFilterRelaySession.subscribeRelaySession.receiveSoftchatIdentity.createNip42AuthenticationRelaySession.subscribeRelaySession.receiveSoftchatIdentity.createNip42AuthenticationRelaySession.subscribeRelaySession.receiveSoftchatIdentity.createNip42AuthenticationThe 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.
- Rust
- Android
- Swift
- TypeScript
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.
session.subscribe(
id = "chat",
filtersJson = listOf(
"""{"kinds":[14],"#p":["${recipient.hex}"],"limit":100}""",
),
)
session.drainOutbound().forEach { outbound ->
socket.sendText(outbound.frame)
session.confirmSent(outbound.deliveryEventIds)
}
val auth = identity.createNip42Authentication(
challenge,
"wss://relay.example",
createdAt,
)
publish(listOf(first, second)) selects EVENTS for a multi-event batch.
The session validates filter/frame JSON in Rust; generated codec declarations
remain internal.
The complete Kotlin recipe above is the custom-socket API. Standard Android
product code calls account.startManagedTransport(context) and observes its
state; it does not execute or parse relay frames in Kotlin.
try session.subscribe(
id: "chat",
filtersJSON: [
"{\"kinds\":[14],\"#p\":[\"\(recipient.hex)\"],\"limit\":100}"
]
)
for frame in try session.drainOutbound() {
try await socket.send(.string(frame.frame))
try session.confirmSent(frame.deliveryEventIDs)
}
let auth = try identity.createNip42Authentication(
challenge: challenge,
relayURL: "wss://relay.example",
createdAt: createdAt
)
publish([first, second]) selects the bounded EVENTS batch.
session.subscribe("chat", [
`{"kinds":[14],"#p":["${recipient}"],"limit":100}`,
]);
for (const outbound of session.drainOutbound()) {
socket.send(outbound.frame);
session.confirmSent(outbound.deliveryEventIds);
}
const auth = identity.createNip42Authentication(
challenge,
"wss://relay.example",
createdAt,
);
session.publish([first, second]) selects the bounded EVENTS batch.
RelaySession lifecycle
relay.sessionDrive 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
- Rust
- Android
- Swift
- TypeScript
RelaySessionRelaySession::confirm_sentOutboundRelayFrameRelaySessionRelaySession.confirmSentOutboundRelayFrameRelaySessionRelaySession.confirmSentOutboundRelayFrameRelaySessionRelaySession.confirmSentOutboundRelayFrameThe 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
- Rust
- Android
- Swift
- TypeScript
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)?;
}
}
RelaySession().use { session ->
execute(session.connect())
session.transportConnected()
session.drainOutbound().forEach { outbound ->
socket.sendText(outbound.frame)
session.confirmSent(outbound.deliveryEventIds)
}
session.receive(frame).forEach { action ->
if (action.kind == RelaySessionAction.Kind.PERSIST_EVENT) {
val event = requireNotNull(action.event)
account.incoming.ingest(listOf(event), receivedAt = now)
session.confirmIngested(event.id)
}
execute(action)
}
}
The Android selector includes the complete callable Kotlin recipe. The
SoftchatAccount call commits before confirmIngested; own both the session
and account access from one serialized coroutine. Use WorkManager or
connectivity policy only to execute the requested reconnect schedule.
let session = RelaySession()
execute(try session.connect())
try session.transportConnected()
for outbound in try session.drainOutbound() {
try await socket.send(.string(outbound.frame))
try session.confirmSent(outbound.deliveryEventIDs)
}
for action in try session.receive(frame) {
if case .persistEvent = action.kind, let event = action.event {
try database.write { try $0.ingest(event) }
try session.confirmIngested(event.id)
}
execute(action)
}
Serialize this code in the same actor that owns the WebSocket and timers.
const session = new RelaySession();
execute(session.connect());
session.transportConnected();
for (const outbound of session.drainOutbound()) {
socket.send(outbound.frame);
session.confirmSent(outbound.deliveryEventIds);
}
for (const action of session.receive(frame)) {
if (action.kind === "persistEvent" && action.event) {
await database.transaction(tx => tx.ingest(action.event));
session.confirmIngested(action.event.id);
}
execute(action);
}
Run the synchronous state-machine calls in one Worker or serialized task.
Call cancel() before close() while a transport is active.
Publishing and delivery
relay.publishPersist 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
- Rust
- Android
- Swift
- TypeScript
RelaySession::publishclassify_delivery_acknowledgementAccountOutgoing.enqueueSignedAccountDelivery.claimAccountDelivery.applyResultsclassifyDeliveryAcknowledgementRelaySession.publishRelaySession.receiveclassifyDeliveryAcknowledgementRelaySession.publishRelaySession.receiveclassifyDeliveryAcknowledgement- Commit the event and publish intent locally.
- Claim committed work and call
publishwith a bounded batch. - Drain a frame, write it to the socket, then call
confirmSent. - Feed complete relay responses to
receive. - Commit every complete
DELIVERY_CHANGEDdecision.
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.
- Rust
- Android
- Swift
- TypeScript
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)?;
}
account.outgoing.enqueueSigned(
operationId = operationId,
events = listOf(event),
relayUrls = listOf(relayUrl),
now = now,
)
val claimed = account.delivery.claim(leaseId, relayUrl, now) ?: return
val eventByIntent = claimed.payloads.associate {
it.intentId to SignedNostrEvent.parse(it.eventJson)
}
val intentByEvent = eventByIntent.entries.associate { (intentId, value) ->
value.id to intentId
}
session.publish(eventByIntent.values.toList())
session.drainOutbound().forEach { outbound ->
socket.sendText(outbound.frame)
account.delivery.markSocketWritten(
claimed.claim,
outbound.deliveryEventIds.mapNotNull(intentByEvent::get),
)
session.confirmSent(outbound.deliveryEventIds)
}
account.delivery.applyResults(claimed.claim, correlatedRelayResults)
The Android selector includes the full Kotlin commit/claim/send/result
sequence. Socket write never means acceptance; only
account.delivery.applyResults changes durable terminal state.
try database.write { try $0.persistPublishIntent(event) }
try session.publish([event])
for frame in try session.drainOutbound() {
try await socket.send(.string(frame.frame))
try session.confirmSent(frame.deliveryEventIDs)
}
await database.persistPublishIntent(event);
session.publish([event]);
for (const outbound of session.drainOutbound()) {
socket.send(outbound.frame);
session.confirmSent(outbound.deliveryEventIds);
}
Durable ingestion contract
relay.ingestionCommit 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
- Rust
- Android
- Swift
- TypeScript
RelaySession::confirm_ingestedvalidate_ingestion_resultAccountIncoming.ingestRelaySession.confirmIngestedRelaySession.confirmIngestedvalidateIngestionResultRelaySession.confirmIngestedvalidateIngestionResultIngestionBatch 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.
- Rust
- Android
- Swift
- TypeScript
database.transaction(|tx| tx.ingest(batch.events()))?;
validate_ingestion_result(&batch, &result)?;
for event in batch.events() {
session.confirm_ingested(&event.id().to_hex())?;
}
val stored = account.incoming.ingest(batch.events, receivedAt = now)
batch.events.forEach { event ->
check(
event.id.hex in stored.receipt.insertedEventIds ||
event.id.hex in stored.receipt.duplicateEventIds ||
event.id.hex in stored.receipt.quarantinedEventIds,
)
session.confirmIngested(event.id)
}
The Android selector includes the full Kotlin call and confirmation recipe. Rust returns inserted, identical-duplicate, and quarantined IDs from the same SQLite transaction; never confirm before the call returns successfully.
try database.write { db in
for event in batch.events { try db.ingest(event) }
}
try validateIngestionResult(batch: batch, result: result)
for event in batch.events { try session.confirmIngested(event.id) }
await database.transaction(tx => batch.events.forEach(tx.ingest));
validateIngestionResult(batch, result);
for (const event of batch.events) session.confirmIngested(event.id);
Recovery
relay.recoveryInspect 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
- Rust
- Android
- Swift
- TypeScript
RelaySession::snapshotRelaySession::cancelAccountDelivery.claimAccountDelivery.releaseRelaySession.transportLostRelaySession.snapshotRelaySession.cancelRelaySession.snapshotRelaySession.cancelRelaySession.closeAfter 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.
- Rust
- Android
- Swift
- TypeScript
let actions = session.transport_lost()?;
log_redacted(session.snapshot());
execute_recovery(actions);
val actions = session.transportLost()
logger.debug(session.snapshot())
activeClaim?.let { account.delivery.release(it.claim) }
actions.forEach(::executeRecovery)
After process death, reopen SoftchatAccount and start a new managed
transport. Rust reclaims expired durable work and rebuilds ephemeral socket
state. Never restore delivery state from the diagnostic snapshot.
let actions = try session.transportLost()
logger.debug("\(session.snapshot())")
actions.forEach(executeRecovery)
const actions = session.transportLost();
logger.debug(session.snapshot());
actions.forEach(executeRecovery);