Skip to main content

softchat/
runtime.rs

1//! Account-scoped ingestion, outgoing-operation, and delivery plans.
2//!
3//! These values deliberately contain no database, socket, HTTP, or lifecycle
4//! callbacks. A platform prepares or receives bounded values in Rust, commits
5//! them through its native store, and returns explicit receipts.
6
7use std::collections::BTreeSet;
8
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::event::{RumorEvent, SignedEvent};
13use crate::{
14    AppDataContext, LocalIdentity, Nip59EnvelopeKind, NostrEventKind, NostrPublicKey, NostrRumor,
15    SignedNostrEvent, SoftchatError, classify_application_data, classify_chat_message,
16    classify_deletion, classify_edit, classify_follow_list, classify_generic_repost,
17    classify_reaction, classify_subject, classify_typing, classify_user_metadata,
18    parse_relay_endpoint,
19};
20
21/// Maximum events prepared in one incoming transaction.
22pub const MAX_ACCOUNT_INGESTION_EVENTS: usize = 150;
23/// Maximum canonical event bytes prepared in one incoming transaction.
24pub const MAX_ACCOUNT_INGESTION_BYTES: usize = 512 * 1_024;
25/// Maximum relay destinations attached to one outgoing operation.
26pub const MAX_OPERATION_RELAYS: usize = 64;
27/// Maximum event copies retained by one outgoing operation.
28pub const MAX_OPERATION_EVENT_COPIES: usize = 512;
29/// Maximum event/relay intents retained by one outgoing operation.
30pub const MAX_OPERATION_DELIVERY_INTENTS: usize = 4_096;
31/// Maximum delivery rows returned in one platform lease.
32pub const MAX_DELIVERY_CLAIM_INTENTS: usize = 100;
33/// Maximum canonical payload bytes returned in one platform lease.
34pub const MAX_DELIVERY_CLAIM_BYTES: usize = 512 * 1_024;
35
36const MAX_ACCOUNT_ID_BYTES: usize = 128;
37const MAX_OPERATION_ID_BYTES: usize = 128;
38const MAX_LEASE_ID_BYTES: usize = 128;
39const MAX_DELIVERY_CATEGORY_BYTES: usize = 64;
40
41/// Semantic projection family selected after authentication.
42#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
43#[serde(rename_all = "camelCase")]
44#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
45pub enum ProjectionKind {
46    /// Authenticated kind-14 message.
47    ChatMessage,
48    /// Authenticated kind-14 subject update.
49    Subject,
50    /// Authenticated kind-7 reaction.
51    Reaction,
52    /// Authenticated kind-5 deletion request.
53    Deletion,
54    /// Authenticated Softchat edit.
55    Edit,
56    /// Authenticated ephemeral typing state.
57    Typing,
58    /// Authenticated private kind-0 metadata.
59    UserMetadata,
60    /// Authenticated private author-copy kind-3 contacts.
61    FollowList,
62    /// Verified direct NIP-78 application data.
63    ApplicationData,
64    /// Verified and self-decrypted Softchat app-data sync.
65    AppDataSync,
66    /// Verified receive-only generic repost.
67    GenericRepost,
68    /// Authenticated event retained as truth without a typed projection.
69    Unknown,
70}
71
72/// One normalized immutable projection mutation.
73#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
74#[serde(rename_all = "camelCase")]
75#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
76pub struct ProjectionMutation {
77    /// Selected projection family.
78    pub kind: ProjectionKind,
79    /// Authenticated logical event or rumor ID.
80    pub logical_event_id: String,
81    /// Authenticated author public key.
82    pub author_public_key: String,
83    /// Portable event timestamp.
84    pub created_at: i64,
85    /// Android-compatible SHA-256 conversation key, or empty for account data.
86    pub conversation_id: String,
87    /// Sorted complete conversation participants, including the author.
88    pub participants: Vec<String>,
89    /// Referenced event IDs for replies, edits, reactions, or deletions.
90    pub target_event_ids: Vec<String>,
91    /// Validated primary text or JSON payload for the selected projection.
92    pub content: String,
93    /// Whether this projection must never become durable message history.
94    pub ephemeral: bool,
95}
96
97/// One fully authenticated event and its optional inner rumor.
98#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
99#[serde(rename_all = "camelCase")]
100#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
101pub struct PreparedIncomingEvent {
102    /// Exact verified outer event.
103    pub outer_event: SignedEvent,
104    /// Authenticated inner rumor for NIP-59 input.
105    pub rumor: Option<RumorEvent>,
106    /// Deterministic projection mutation.
107    pub projection: ProjectionMutation,
108}
109
110/// One bounded atomic ingestion request for a platform-native database.
111#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
112#[serde(rename_all = "camelCase")]
113#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
114pub struct PreparedIncomingBatch {
115    /// Opaque non-secret account partition.
116    pub account_id: String,
117    /// Total canonical outer-event bytes used for batching.
118    pub canonical_bytes: u64,
119    /// Verified events and deterministic projections.
120    pub events: Vec<PreparedIncomingEvent>,
121}
122
123/// Platform result for one complete incoming transaction.
124#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
125#[serde(rename_all = "camelCase", deny_unknown_fields)]
126#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
127pub struct IngestionReceipt {
128    /// Opaque account partition committed by the store.
129    pub account_id: String,
130    /// IDs inserted for the first time.
131    pub inserted_event_ids: Vec<String>,
132    /// IDs already present with identical canonical bytes.
133    pub duplicate_event_ids: Vec<String>,
134    /// IDs deliberately quarantined by a conflict or storage policy.
135    pub quarantined_event_ids: Vec<String>,
136}
137
138/// One independently randomized NIP-59 recipient copy.
139#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
140#[serde(rename_all = "camelCase")]
141#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
142pub struct PreparedEventCopy {
143    /// Stable outer event ID, also used as the event-copy ID.
144    pub event_copy_id: String,
145    /// Intended recipient of this independently wrapped copy.
146    pub recipient_public_key: String,
147    /// Exact signed relay-publishable event.
148    pub event: SignedEvent,
149}
150
151/// One durable event-copy/relay delivery intent.
152#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
153#[serde(rename_all = "camelCase")]
154#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
155pub struct PreparedRelayIntent {
156    /// Deterministic ID derived from operation, event copy, and relay.
157    pub intent_id: String,
158    /// Owning operation.
159    pub operation_id: String,
160    /// Event copy sent by this intent.
161    pub event_copy_id: String,
162    /// Canonical relay URL.
163    pub relay_url: String,
164    /// Canonical event JSON byte count used for bounded claims.
165    pub payload_bytes: u64,
166}
167
168/// One outgoing operation that must be persisted before any socket write.
169#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
170#[serde(rename_all = "camelCase")]
171#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
172pub struct PreparedOutgoingOperation {
173    /// Opaque non-secret account partition.
174    pub account_id: String,
175    /// Caller-supplied idempotency/operation ID.
176    pub operation_id: String,
177    /// Independently wrapped event copies.
178    pub event_copies: Vec<PreparedEventCopy>,
179    /// Required per-copy, per-relay delivery intents.
180    pub relay_intents: Vec<PreparedRelayIntent>,
181}
182
183/// Durable delivery-intent state.
184#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
185#[serde(rename_all = "camelCase")]
186#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
187pub enum DeliveryIntentState {
188    /// Persisted and available to claim.
189    Pending,
190    /// Exclusively leased by one drain worker.
191    Claimed,
192    /// Bytes were written to a socket; relay acceptance is still unknown.
193    SocketWritten,
194    /// Relay returned a terminal acceptance.
195    Accepted,
196    /// Relay returned a permanent rejection.
197    Rejected,
198    /// A retryable result returned the intent to the queue.
199    Retryable,
200    /// User or account lifecycle cancelled the intent.
201    Cancelled,
202}
203
204/// Store snapshot used by the pure delivery reducer.
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(rename_all = "camelCase", deny_unknown_fields)]
207#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
208pub struct DeliveryIntentSnapshot {
209    /// Stable intent ID.
210    pub intent_id: String,
211    /// Owning operation ID.
212    pub operation_id: String,
213    /// Event-copy ID.
214    pub event_copy_id: String,
215    /// Canonical relay URL.
216    pub relay_url: String,
217    /// Canonical payload byte count.
218    pub payload_bytes: u64,
219    /// Current durable state.
220    pub state: DeliveryIntentState,
221    /// Existing lease ID, or empty.
222    pub lease_id: String,
223    /// Number of completed claim attempts.
224    pub attempt_count: u32,
225}
226
227/// State mutation returned for an atomic platform transaction.
228#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
229#[serde(rename_all = "camelCase")]
230#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
231pub struct DeliveryStateMutation {
232    /// Stable intent ID.
233    pub intent_id: String,
234    /// Required new state.
235    pub state: DeliveryIntentState,
236    /// New lease ID, or empty when the lease must be cleared.
237    pub lease_id: String,
238    /// Stable redacted result category.
239    pub category: String,
240    /// New attempt count.
241    pub attempt_count: u32,
242}
243
244/// One bounded exclusive delivery lease.
245#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
246#[serde(rename_all = "camelCase")]
247#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
248pub struct DeliveryClaim {
249    /// Host-generated lease ID.
250    pub lease_id: String,
251    /// Claimed rows in stable input order.
252    pub intents: Vec<DeliveryIntentSnapshot>,
253    /// Sum of canonical payload bytes.
254    pub payload_bytes: u64,
255    /// Atomic state mutations the store must commit before sending.
256    pub mutations: Vec<DeliveryStateMutation>,
257}
258
259/// Relay result class accepted by the delivery reducer.
260#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
261#[serde(rename_all = "camelCase")]
262#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
263pub enum RelayDeliveryResultKind {
264    /// Terminal relay acceptance.
265    Accepted,
266    /// Terminal permanent rejection.
267    Rejected,
268    /// Retryable network, rate, or temporary relay failure.
269    Retryable,
270}
271
272/// Stable redacted relay result.
273#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
276pub struct RelayDeliveryResult {
277    /// Intent receiving the result.
278    pub intent_id: String,
279    /// Stable result class.
280    pub kind: RelayDeliveryResultKind,
281    /// Stable bounded category; never arbitrary relay prose.
282    pub category: String,
283}
284
285/// User-visible aggregate operation state.
286#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
287#[serde(rename_all = "camelCase")]
288#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
289pub enum OperationState {
290    /// Local preparation has not produced durable relay intent yet.
291    Preparing,
292    /// Durable media prerequisites are still running.
293    Uploading,
294    /// All remaining work is durably queued.
295    Queued,
296    /// At least one delivery is leased or socket-written.
297    Sending,
298    /// Some required intents accepted while others remain or failed.
299    PartiallySent,
300    /// Every required intent was accepted.
301    Sent,
302    /// No intent was accepted and a permanent rejection exists.
303    Failed,
304    /// Every remaining intent was explicitly cancelled.
305    Cancelled,
306}
307
308/// Aggregate operation state derived entirely from durable intent rows.
309#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
310#[serde(rename_all = "camelCase")]
311#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
312pub struct OperationSnapshot {
313    /// Owning operation ID.
314    pub operation_id: String,
315    /// Derived UI state.
316    pub state: OperationState,
317    /// Required intent count.
318    pub total_intents: u32,
319    /// Terminal accepted count.
320    pub accepted_intents: u32,
321    /// Terminal rejected count.
322    pub rejected_intents: u32,
323    /// Retryable/pending count.
324    pub queued_intents: u32,
325    /// Claimed/socket-written count.
326    pub active_intents: u32,
327}
328
329/// Stateless semantic account planner.
330#[derive(Clone, Copy, Debug, Default)]
331pub struct AccountEngine;
332
333impl AccountEngine {
334    /// Validate one opaque non-secret account partition identifier.
335    ///
336    /// # Errors
337    ///
338    /// Returns [`SoftchatError::InvalidAccountOperation`] for an empty,
339    /// oversized, or non-portable identifier.
340    pub fn validate_account_id(account_id: &str) -> Result<(), SoftchatError> {
341        validate_identifier(account_id, MAX_ACCOUNT_ID_BYTES)
342    }
343
344    /// Authenticate a bounded incoming batch and produce immutable store values.
345    ///
346    /// # Errors
347    ///
348    /// Returns a stable account, event, envelope, or projection error before
349    /// the platform begins a transaction.
350    pub fn prepare_incoming(
351        identity: &LocalIdentity,
352        account_id: String,
353        events: Vec<SignedEvent>,
354    ) -> Result<PreparedIncomingBatch, SoftchatError> {
355        Self::validate_account_id(&account_id)?;
356        if events.is_empty() || events.len() > MAX_ACCOUNT_INGESTION_EVENTS {
357            return Err(SoftchatError::InvalidAccountOperation);
358        }
359
360        let mut canonical_bytes = 0_u64;
361        let mut unique_ids = BTreeSet::new();
362        let mut prepared = Vec::with_capacity(events.len());
363        for event in events {
364            let outer = SignedNostrEvent::try_from(event)?;
365            let outer_json = outer.to_json()?;
366            crate::session::validate_event_frame_json(&outer_json)?;
367            canonical_bytes = canonical_bytes
368                .checked_add(
369                    u64::try_from(outer_json.len())
370                        .map_err(|_| SoftchatError::InvalidAccountOperation)?,
371                )
372                .ok_or(SoftchatError::InvalidAccountOperation)?;
373            if canonical_bytes > MAX_ACCOUNT_INGESTION_BYTES as u64
374                || !unique_ids.insert(outer.id().to_hex())
375            {
376                return Err(SoftchatError::InvalidAccountOperation);
377            }
378
379            let (rumor, projection) = if matches!(
380                outer.kind(),
381                NostrEventKind::GIFT_WRAP | NostrEventKind::EPHEMERAL_GIFT_WRAP
382            ) {
383                let unwrapped = identity.unwrap_gift_wrap(&outer)?;
384                let rumor = unwrapped.into_rumor();
385                let projection = projection_for_rumor(
386                    &rumor,
387                    &identity.public_key(),
388                    outer.kind() == NostrEventKind::EPHEMERAL_GIFT_WRAP,
389                )?;
390                (Some(RumorEvent::from(&rumor)), projection)
391            } else {
392                (None, projection_for_signed(identity, &outer)?)
393            };
394            prepared.push(PreparedIncomingEvent {
395                outer_event: SignedEvent::from(&outer),
396                rumor,
397                projection,
398            });
399        }
400
401        Ok(PreparedIncomingBatch {
402            account_id,
403            canonical_bytes,
404            events: prepared,
405        })
406    }
407
408    /// Verify that a platform transaction handled every prepared event once.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`SoftchatError::InvalidPersistenceResult`] for the wrong
413    /// account, unknown IDs, duplicated classifications, or incomplete output.
414    pub fn validate_ingestion_receipt(
415        batch: &PreparedIncomingBatch,
416        receipt: &IngestionReceipt,
417    ) -> Result<(), SoftchatError> {
418        if batch.account_id != receipt.account_id {
419            return Err(SoftchatError::InvalidPersistenceResult);
420        }
421        let expected = batch
422            .events
423            .iter()
424            .map(|event| event.outer_event.id.clone())
425            .collect::<BTreeSet<_>>();
426        let mut actual = BTreeSet::new();
427        for event_id in receipt
428            .inserted_event_ids
429            .iter()
430            .chain(&receipt.duplicate_event_ids)
431            .chain(&receipt.quarantined_event_ids)
432        {
433            if !actual.insert(event_id.clone()) {
434                return Err(SoftchatError::InvalidPersistenceResult);
435            }
436        }
437        if expected == actual {
438            Ok(())
439        } else {
440            Err(SoftchatError::InvalidPersistenceResult)
441        }
442    }
443
444    /// Create independently randomized NIP-59 copies and required relay intents.
445    ///
446    /// The returned operation must be committed before any frame is sent.
447    ///
448    /// # Errors
449    ///
450    /// Returns a stable operation, relay URL, recipient, or envelope error.
451    #[allow(clippy::too_many_arguments)]
452    pub fn prepare_rumor_operation(
453        identity: &LocalIdentity,
454        account_id: String,
455        operation_id: String,
456        rumor: RumorEvent,
457        recipients: Vec<String>,
458        notification_recipients: Vec<String>,
459        relay_urls: Vec<String>,
460        kind: Nip59EnvelopeKind,
461        now: i64,
462    ) -> Result<PreparedOutgoingOperation, SoftchatError> {
463        Self::validate_account_id(&account_id)?;
464        validate_identifier(&operation_id, MAX_OPERATION_ID_BYTES)?;
465        let now = u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
466        let rumor =
467            NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidAccountOperation)?;
468        if rumor.public_key() != identity.public_key() {
469            return Err(SoftchatError::InvalidAccountOperation);
470        }
471
472        let mut recipients = recipients
473            .iter()
474            .map(|value| {
475                NostrPublicKey::from_hex(value).map_err(|_| SoftchatError::InvalidAccountOperation)
476            })
477            .collect::<Result<Vec<_>, _>>()?;
478        let recipient_ids = recipients
479            .iter()
480            .map(NostrPublicKey::to_hex)
481            .collect::<BTreeSet<_>>();
482        let notification_recipients = notification_recipients
483            .into_iter()
484            .map(|value| {
485                NostrPublicKey::from_hex(&value)
486                    .map(|key| key.to_hex())
487                    .map_err(|_| SoftchatError::InvalidAccountOperation)
488            })
489            .collect::<Result<BTreeSet<_>, _>>()?;
490        if !notification_recipients.is_subset(&recipient_ids) {
491            return Err(SoftchatError::InvalidAccountOperation);
492        }
493        crate::envelope::validate_nip59_wire_size(
494            &rumor,
495            kind,
496            now,
497            !notification_recipients.is_empty(),
498        )?;
499        recipients.push(identity.public_key());
500        recipients.sort_by_key(NostrPublicKey::to_hex);
501        recipients.dedup();
502        if recipients.is_empty() || recipients.len() > MAX_OPERATION_EVENT_COPIES {
503            return Err(SoftchatError::InvalidAccountOperation);
504        }
505
506        let relays = canonical_relay_urls(relay_urls)?;
507        let mut event_copies = Vec::with_capacity(recipients.len());
508        for recipient in recipients {
509            let notify_recipient = notification_recipients.contains(&recipient.to_hex());
510            let event = identity.gift_wrap_for_operation_prevalidated(
511                &recipient,
512                &rumor,
513                kind,
514                now,
515                notify_recipient,
516            )?;
517            event_copies.push(PreparedEventCopy {
518                event_copy_id: event.id().to_hex(),
519                recipient_public_key: recipient.to_hex(),
520                event: SignedEvent::from(&event),
521            });
522        }
523        prepare_operation(account_id, operation_id, event_copies, relays)
524    }
525
526    /// Persist one or more already signed direct events as an outgoing operation.
527    ///
528    /// # Errors
529    ///
530    /// Returns a stable operation or relay URL error.
531    pub fn prepare_signed_operation(
532        account_id: String,
533        operation_id: String,
534        events: Vec<SignedEvent>,
535        relay_urls: Vec<String>,
536    ) -> Result<PreparedOutgoingOperation, SoftchatError> {
537        Self::validate_account_id(&account_id)?;
538        validate_identifier(&operation_id, MAX_OPERATION_ID_BYTES)?;
539        if events.is_empty() || events.len() > MAX_OPERATION_EVENT_COPIES {
540            return Err(SoftchatError::InvalidAccountOperation);
541        }
542        let mut unique = BTreeSet::new();
543        let mut event_copies = Vec::with_capacity(events.len());
544        for event in events {
545            let event = SignedNostrEvent::try_from(event)?;
546            let event_copy_id = event.id().to_hex();
547            if !unique.insert(event_copy_id.clone()) {
548                return Err(SoftchatError::InvalidAccountOperation);
549            }
550            event_copies.push(PreparedEventCopy {
551                event_copy_id,
552                recipient_public_key: String::new(),
553                event: SignedEvent::from(&event),
554            });
555        }
556        prepare_operation(
557            account_id,
558            operation_id,
559            event_copies,
560            canonical_relay_urls(relay_urls)?,
561        )
562    }
563}
564
565/// Stateless durable-delivery reducer.
566#[derive(Clone, Copy, Debug, Default)]
567pub struct DeliveryReducer;
568
569impl DeliveryReducer {
570    /// Claim a bounded prefix of pending or retryable intents.
571    ///
572    /// # Errors
573    ///
574    /// Returns a stable delivery error for malformed rows, a reused lease, or
575    /// a first payload that cannot fit the claim profile.
576    pub fn claim(
577        intents: Vec<DeliveryIntentSnapshot>,
578        lease_id: String,
579    ) -> Result<DeliveryClaim, SoftchatError> {
580        validate_identifier(&lease_id, MAX_LEASE_ID_BYTES)
581            .map_err(|_| SoftchatError::InvalidDeliveryState)?;
582        let mut claimed = Vec::new();
583        let mut mutations = Vec::new();
584        let mut payload_bytes = 0_u64;
585        let mut unique = BTreeSet::new();
586        for mut intent in intents {
587            validate_intent_snapshot(&intent)?;
588            if !unique.insert(intent.intent_id.clone()) {
589                return Err(SoftchatError::InvalidDeliveryState);
590            }
591            if !matches!(
592                intent.state,
593                DeliveryIntentState::Pending | DeliveryIntentState::Retryable
594            ) {
595                continue;
596            }
597            if claimed.len() >= MAX_DELIVERY_CLAIM_INTENTS {
598                break;
599            }
600            let next_bytes = payload_bytes
601                .checked_add(intent.payload_bytes)
602                .ok_or(SoftchatError::InvalidDeliveryState)?;
603            if next_bytes > MAX_DELIVERY_CLAIM_BYTES as u64 {
604                if claimed.is_empty() {
605                    return Err(SoftchatError::InvalidDeliveryState);
606                }
607                break;
608            }
609            payload_bytes = next_bytes;
610            let attempt_count = intent.attempt_count.saturating_add(1);
611            mutations.push(DeliveryStateMutation {
612                intent_id: intent.intent_id.clone(),
613                state: DeliveryIntentState::Claimed,
614                lease_id: lease_id.clone(),
615                category: "claimed".to_owned(),
616                attempt_count,
617            });
618            intent.state = DeliveryIntentState::Claimed;
619            intent.lease_id.clone_from(&lease_id);
620            intent.attempt_count = attempt_count;
621            claimed.push(intent);
622        }
623        if claimed.is_empty() {
624            return Err(SoftchatError::InvalidDeliveryState);
625        }
626        Ok(DeliveryClaim {
627            lease_id,
628            intents: claimed,
629            payload_bytes,
630            mutations,
631        })
632    }
633
634    /// Mark an exact subset of one lease as socket-written.
635    ///
636    /// # Errors
637    ///
638    /// Returns a stable delivery error for stale leases, duplicate IDs, or
639    /// rows that were not in the claimed state.
640    pub fn mark_socket_written(
641        claim: &DeliveryClaim,
642        intent_ids: Vec<String>,
643    ) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
644        transition_claim_subset(
645            claim,
646            intent_ids,
647            DeliveryIntentState::Claimed,
648            DeliveryIntentState::SocketWritten,
649            "socket_written",
650            true,
651        )
652    }
653
654    /// Apply stable per-intent relay outcomes after a socket write.
655    ///
656    /// # Errors
657    ///
658    /// Returns a stable delivery error for stale leases, duplicate/unknown
659    /// IDs, invalid categories, or contradictory terminal transitions.
660    pub fn apply_relay_results(
661        claim: &DeliveryClaim,
662        results: Vec<RelayDeliveryResult>,
663    ) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
664        if results.is_empty() {
665            return Err(SoftchatError::InvalidDeliveryState);
666        }
667        let claimed = claim
668            .intents
669            .iter()
670            .map(|intent| (intent.intent_id.as_str(), intent))
671            .collect::<std::collections::BTreeMap<_, _>>();
672        let mut seen = BTreeSet::new();
673        let mut mutations = Vec::with_capacity(results.len());
674        for result in results {
675            if !seen.insert(result.intent_id.clone())
676                || result.category.is_empty()
677                || result.category.len() > MAX_DELIVERY_CATEGORY_BYTES
678                || !result
679                    .category
680                    .bytes()
681                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
682            {
683                return Err(SoftchatError::InvalidDeliveryState);
684            }
685            let intent = claimed
686                .get(result.intent_id.as_str())
687                .ok_or(SoftchatError::InvalidDeliveryState)?;
688            if intent.state != DeliveryIntentState::SocketWritten
689                && intent.state != DeliveryIntentState::Claimed
690            {
691                return Err(SoftchatError::InvalidDeliveryState);
692            }
693            let state = match result.kind {
694                RelayDeliveryResultKind::Accepted => DeliveryIntentState::Accepted,
695                RelayDeliveryResultKind::Rejected => DeliveryIntentState::Rejected,
696                RelayDeliveryResultKind::Retryable => DeliveryIntentState::Retryable,
697            };
698            mutations.push(DeliveryStateMutation {
699                intent_id: result.intent_id,
700                state,
701                lease_id: String::new(),
702                category: result.category,
703                attempt_count: intent.attempt_count,
704            });
705        }
706        Ok(mutations)
707    }
708
709    /// Return every unfinished row in a lease to the retry queue.
710    ///
711    /// # Errors
712    ///
713    /// Returns a stable delivery error for an invalid claim.
714    pub fn release_claim(
715        claim: &DeliveryClaim,
716    ) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
717        if claim.intents.is_empty() {
718            return Err(SoftchatError::InvalidDeliveryState);
719        }
720        claim
721            .intents
722            .iter()
723            .map(|intent| {
724                validate_intent_snapshot(intent)?;
725                if !matches!(
726                    intent.state,
727                    DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten
728                ) {
729                    return Err(SoftchatError::InvalidDeliveryState);
730                }
731                Ok(DeliveryStateMutation {
732                    intent_id: intent.intent_id.clone(),
733                    state: DeliveryIntentState::Retryable,
734                    lease_id: String::new(),
735                    category: "lease_released".to_owned(),
736                    attempt_count: intent.attempt_count,
737                })
738            })
739            .collect()
740    }
741
742    /// Derive one user-visible operation snapshot from durable intent rows.
743    ///
744    /// # Errors
745    ///
746    /// Returns a stable operation error for empty or mixed-operation input.
747    pub fn operation_snapshot(
748        intents: Vec<DeliveryIntentSnapshot>,
749    ) -> Result<OperationSnapshot, SoftchatError> {
750        let operation_id = intents
751            .first()
752            .map(|intent| intent.operation_id.clone())
753            .ok_or(SoftchatError::InvalidAccountOperation)?;
754        validate_identifier(&operation_id, MAX_OPERATION_ID_BYTES)?;
755        let mut accepted = 0_u32;
756        let mut rejected = 0_u32;
757        let mut queued = 0_u32;
758        let mut active = 0_u32;
759        let mut cancelled = 0_u32;
760        for intent in &intents {
761            validate_intent_snapshot(intent)?;
762            if intent.operation_id != operation_id {
763                return Err(SoftchatError::InvalidAccountOperation);
764            }
765            match intent.state {
766                DeliveryIntentState::Accepted => accepted = accepted.saturating_add(1),
767                DeliveryIntentState::Rejected => rejected = rejected.saturating_add(1),
768                DeliveryIntentState::Pending | DeliveryIntentState::Retryable => {
769                    queued = queued.saturating_add(1);
770                }
771                DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten => {
772                    active = active.saturating_add(1);
773                }
774                DeliveryIntentState::Cancelled => cancelled = cancelled.saturating_add(1),
775            }
776        }
777        let total =
778            u32::try_from(intents.len()).map_err(|_| SoftchatError::InvalidAccountOperation)?;
779        let state = if accepted == total {
780            OperationState::Sent
781        } else if cancelled == total {
782            OperationState::Cancelled
783        } else if accepted > 0 {
784            OperationState::PartiallySent
785        } else if rejected > 0 {
786            OperationState::Failed
787        } else if active > 0 {
788            OperationState::Sending
789        } else {
790            OperationState::Queued
791        };
792        Ok(OperationSnapshot {
793            operation_id,
794            state,
795            total_intents: total,
796            accepted_intents: accepted,
797            rejected_intents: rejected,
798            queued_intents: queued,
799            active_intents: active,
800        })
801    }
802}
803
804/// Compute the released-Android conversation ID from complete participants.
805///
806/// # Errors
807///
808/// Returns a stable account-operation error unless there are at least two
809/// unique canonical public keys.
810pub fn conversation_id(public_keys: Vec<String>) -> Result<String, SoftchatError> {
811    if public_keys.len() < 2 || public_keys.len() > crate::MAX_CHAT_PARTICIPANTS + 1 {
812        return Err(SoftchatError::InvalidAccountOperation);
813    }
814    let mut keys = public_keys
815        .iter()
816        .map(|value| {
817            NostrPublicKey::from_hex(value).map_err(|_| SoftchatError::InvalidAccountOperation)
818        })
819        .collect::<Result<Vec<_>, _>>()?;
820    keys.sort_by_key(NostrPublicKey::to_hex);
821    let original_len = keys.len();
822    keys.dedup();
823    if keys.len() != original_len {
824        return Err(SoftchatError::InvalidAccountOperation);
825    }
826    let mut digest = Sha256::new();
827    for key in keys {
828        digest.update(key.as_inner().as_bytes());
829    }
830    Ok(hex::encode(digest.finalize()))
831}
832
833fn prepare_operation(
834    account_id: String,
835    operation_id: String,
836    event_copies: Vec<PreparedEventCopy>,
837    relay_urls: Vec<String>,
838) -> Result<PreparedOutgoingOperation, SoftchatError> {
839    let intent_count = event_copies
840        .len()
841        .checked_mul(relay_urls.len())
842        .ok_or(SoftchatError::InvalidAccountOperation)?;
843    if intent_count == 0 || intent_count > MAX_OPERATION_DELIVERY_INTENTS {
844        return Err(SoftchatError::InvalidAccountOperation);
845    }
846    let mut relay_intents = Vec::with_capacity(intent_count);
847    for event_copy in &event_copies {
848        let event = SignedNostrEvent::try_from(event_copy.event.clone())?;
849        let event_json = event.to_json()?;
850        crate::session::validate_event_frame_json(&event_json)?;
851        let payload_bytes =
852            u64::try_from(event_json.len()).map_err(|_| SoftchatError::InvalidAccountOperation)?;
853        for relay_url in &relay_urls {
854            relay_intents.push(PreparedRelayIntent {
855                intent_id: stable_intent_id(&operation_id, &event_copy.event_copy_id, relay_url),
856                operation_id: operation_id.clone(),
857                event_copy_id: event_copy.event_copy_id.clone(),
858                relay_url: relay_url.clone(),
859                payload_bytes,
860            });
861        }
862    }
863    Ok(PreparedOutgoingOperation {
864        account_id,
865        operation_id,
866        event_copies,
867        relay_intents,
868    })
869}
870
871fn projection_for_rumor(
872    rumor: &NostrRumor,
873    authenticated_recipient: &NostrPublicKey,
874    ephemeral: bool,
875) -> Result<ProjectionMutation, SoftchatError> {
876    let record = RumorEvent::from(rumor);
877    let (kind, target_event_ids, content) = match rumor.kind() {
878        NostrEventKind::PRIVATE_DIRECT_MESSAGE => {
879            if let Ok(view) = classify_chat_message(record.clone()) {
880                let targets = (!view.related_event_id.is_empty())
881                    .then_some(view.related_event_id)
882                    .into_iter()
883                    .collect();
884                (ProjectionKind::ChatMessage, targets, view.rumor.content)
885            } else {
886                let view = classify_subject(record.clone())?;
887                (ProjectionKind::Subject, Vec::new(), view.subject)
888            }
889        }
890        NostrEventKind::REACTION => {
891            let view = classify_reaction(record.clone())?;
892            (ProjectionKind::Reaction, vec![view.event_id], view.reaction)
893        }
894        NostrEventKind::EVENT_DELETION_REQUEST => {
895            let view = classify_deletion(record.clone())?;
896            (ProjectionKind::Deletion, view.event_ids, view.reason)
897        }
898        NostrEventKind::UPDATED_CONTENT => {
899            let view = classify_edit(record.clone())?;
900            (
901                ProjectionKind::Edit,
902                vec![view.original_event_id],
903                view.content,
904            )
905        }
906        NostrEventKind::TYPING => {
907            classify_typing(record.clone())?;
908            (ProjectionKind::Typing, Vec::new(), String::new())
909        }
910        NostrEventKind::USER_METADATA => {
911            let view = classify_user_metadata(record.clone())?;
912            (ProjectionKind::UserMetadata, Vec::new(), view.rumor.content)
913        }
914        NostrEventKind::FOLLOW_LIST => {
915            classify_follow_list(record.clone())?;
916            (ProjectionKind::FollowList, Vec::new(), String::new())
917        }
918        _ => (
919            ProjectionKind::Unknown,
920            Vec::new(),
921            rumor.content().to_owned(),
922        ),
923    };
924    // Only chat-domain rumors are allowed to create product conversations.
925    // Account-domain rumors such as private follow lists also contain `p`
926    // tags, but those tags are contacts rather than conversation members.
927    let (conversation_id, participants) = if matches!(
928        kind,
929        ProjectionKind::ChatMessage
930            | ProjectionKind::Subject
931            | ProjectionKind::Reaction
932            | ProjectionKind::Deletion
933            | ProjectionKind::Edit
934            | ProjectionKind::Typing
935    ) {
936        conversation_for_rumor(rumor, authenticated_recipient)?
937    } else {
938        (String::new(), Vec::new())
939    };
940    Ok(ProjectionMutation {
941        kind,
942        logical_event_id: rumor.id().to_hex(),
943        author_public_key: rumor.public_key().to_hex(),
944        created_at: i64::try_from(rumor.created_at())
945            .map_err(|_| SoftchatError::InvalidAccountOperation)?,
946        conversation_id,
947        participants,
948        target_event_ids,
949        content,
950        ephemeral,
951    })
952}
953
954fn projection_for_signed(
955    identity: &LocalIdentity,
956    event: &SignedNostrEvent,
957) -> Result<ProjectionMutation, SoftchatError> {
958    let record = SignedEvent::from(event);
959    let (kind, content) = match event.kind() {
960        NostrEventKind::APPLICATION_DATA => {
961            let view = classify_application_data(record.clone())?;
962            (ProjectionKind::ApplicationData, view.content)
963        }
964        NostrEventKind::APPLICATION_DATA_SYNC => {
965            let view = identity.decrypt_app_data_sync(event)?;
966            let context = match view.context {
967                AppDataContext::ReadState => "read-state",
968                AppDataContext::AppSettings => "app-settings",
969            };
970            (
971                ProjectionKind::AppDataSync,
972                format!("{context}:{}", view.json),
973            )
974        }
975        NostrEventKind::GENERIC_REPOST => {
976            classify_generic_repost(record)?;
977            (ProjectionKind::GenericRepost, event.content().to_owned())
978        }
979        _ => (ProjectionKind::Unknown, event.content().to_owned()),
980    };
981    Ok(ProjectionMutation {
982        kind,
983        logical_event_id: event.id().to_hex(),
984        author_public_key: event.public_key().to_hex(),
985        created_at: i64::try_from(event.created_at())
986            .map_err(|_| SoftchatError::InvalidAccountOperation)?,
987        conversation_id: String::new(),
988        participants: Vec::new(),
989        target_event_ids: Vec::new(),
990        content,
991        ephemeral: false,
992    })
993}
994
995fn conversation_for_rumor(
996    rumor: &NostrRumor,
997    authenticated_recipient: &NostrPublicKey,
998) -> Result<(String, Vec<String>), SoftchatError> {
999    let participant_count = rumor
1000        .tags()
1001        .filter(|tag| tag.first().map(String::as_str) == Some("p"))
1002        .count();
1003    if participant_count == 0 || participant_count > crate::MAX_CHAT_PARTICIPANTS {
1004        return Err(SoftchatError::InvalidSoftchatEvent);
1005    }
1006
1007    let author = rumor.public_key();
1008    let mut participants = Vec::with_capacity(participant_count.saturating_add(1));
1009    for tag in rumor
1010        .tags()
1011        .filter(|tag| tag.first().map(String::as_str) == Some("p"))
1012    {
1013        let value = tag.get(1).ok_or(SoftchatError::InvalidSoftchatEvent)?;
1014        let participant =
1015            NostrPublicKey::from_hex(value).map_err(|_| SoftchatError::InvalidSoftchatEvent)?;
1016        if participant == author {
1017            return Err(SoftchatError::InvalidSoftchatEvent);
1018        }
1019        participants.push(participant);
1020    }
1021    participants.sort_by_key(NostrPublicKey::to_hex);
1022    let original_len = participants.len();
1023    participants.dedup();
1024    if participants.len() != original_len {
1025        return Err(SoftchatError::InvalidSoftchatEvent);
1026    }
1027    participants.push(author);
1028    participants.sort_by_key(NostrPublicKey::to_hex);
1029    if !participants.contains(authenticated_recipient) || participants.len() < 2 {
1030        return Err(SoftchatError::InvalidSoftchatEvent);
1031    }
1032    let participants = participants
1033        .into_iter()
1034        .map(|key| key.to_hex())
1035        .collect::<Vec<_>>();
1036    Ok((conversation_id(participants.clone())?, participants))
1037}
1038
1039fn canonical_relay_urls(values: Vec<String>) -> Result<Vec<String>, SoftchatError> {
1040    if values.is_empty() || values.len() > MAX_OPERATION_RELAYS {
1041        return Err(SoftchatError::InvalidAccountOperation);
1042    }
1043    let mut relays = values
1044        .iter()
1045        .map(|value| {
1046            parse_relay_endpoint(value)
1047                .map(|endpoint| endpoint.configured_url)
1048                .map_err(|_| SoftchatError::InvalidAccountOperation)
1049        })
1050        .collect::<Result<Vec<_>, _>>()?;
1051    relays.sort();
1052    let original_len = relays.len();
1053    relays.dedup();
1054    if relays.len() != original_len {
1055        return Err(SoftchatError::InvalidAccountOperation);
1056    }
1057    Ok(relays)
1058}
1059
1060pub(crate) fn stable_intent_id(operation_id: &str, event_copy_id: &str, relay_url: &str) -> String {
1061    let mut digest = Sha256::new();
1062    digest.update(operation_id.as_bytes());
1063    digest.update([0]);
1064    digest.update(event_copy_id.as_bytes());
1065    digest.update([0]);
1066    digest.update(relay_url.as_bytes());
1067    hex::encode(digest.finalize())
1068}
1069
1070fn validate_identifier(value: &str, max_bytes: usize) -> Result<(), SoftchatError> {
1071    if value.is_empty()
1072        || value.len() > max_bytes
1073        || !value
1074            .bytes()
1075            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
1076    {
1077        Err(SoftchatError::InvalidAccountOperation)
1078    } else {
1079        Ok(())
1080    }
1081}
1082
1083fn validate_intent_snapshot(intent: &DeliveryIntentSnapshot) -> Result<(), SoftchatError> {
1084    validate_identifier(&intent.intent_id, MAX_OPERATION_ID_BYTES)
1085        .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1086    validate_identifier(&intent.operation_id, MAX_OPERATION_ID_BYTES)
1087        .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1088    if intent.event_copy_id.len() != 64
1089        || hex::decode(&intent.event_copy_id)
1090            .ok()
1091            .is_none_or(|bytes| bytes.len() != 32)
1092        || intent.payload_bytes == 0
1093        || intent.payload_bytes > crate::MAX_NOSTR_EVENT_JSON_BYTES as u64
1094    {
1095        return Err(SoftchatError::InvalidDeliveryState);
1096    }
1097    canonical_relay_urls(vec![intent.relay_url.clone()])
1098        .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1099    if matches!(
1100        intent.state,
1101        DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten
1102    ) {
1103        validate_identifier(&intent.lease_id, MAX_LEASE_ID_BYTES)
1104            .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1105    } else if !intent.lease_id.is_empty() {
1106        return Err(SoftchatError::InvalidDeliveryState);
1107    }
1108    Ok(())
1109}
1110
1111fn transition_claim_subset(
1112    claim: &DeliveryClaim,
1113    intent_ids: Vec<String>,
1114    expected: DeliveryIntentState,
1115    next: DeliveryIntentState,
1116    category: &str,
1117    retain_lease: bool,
1118) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
1119    if intent_ids.is_empty() {
1120        return Err(SoftchatError::InvalidDeliveryState);
1121    }
1122    validate_identifier(&claim.lease_id, MAX_LEASE_ID_BYTES)
1123        .map_err(|_| SoftchatError::InvalidDeliveryState)?;
1124    let claimed = claim
1125        .intents
1126        .iter()
1127        .map(|intent| (intent.intent_id.as_str(), intent))
1128        .collect::<std::collections::BTreeMap<_, _>>();
1129    let mut seen = BTreeSet::new();
1130    intent_ids
1131        .into_iter()
1132        .map(|intent_id| {
1133            if !seen.insert(intent_id.clone()) {
1134                return Err(SoftchatError::InvalidDeliveryState);
1135            }
1136            let intent = claimed
1137                .get(intent_id.as_str())
1138                .ok_or(SoftchatError::InvalidDeliveryState)?;
1139            if intent.state != expected
1140                || (!intent.lease_id.is_empty() && intent.lease_id != claim.lease_id)
1141            {
1142                return Err(SoftchatError::InvalidDeliveryState);
1143            }
1144            Ok(DeliveryStateMutation {
1145                intent_id,
1146                state: next,
1147                lease_id: if retain_lease {
1148                    claim.lease_id.clone()
1149                } else {
1150                    String::new()
1151                },
1152                category: category.to_owned(),
1153                attempt_count: intent.attempt_count,
1154            })
1155        })
1156        .collect()
1157}
1158
1159#[cfg(test)]
1160#[allow(clippy::unwrap_used)]
1161mod tests {
1162    use super::*;
1163    use crate::{ChatMessageDraft, ChatRelation, NostrEventDraft};
1164
1165    const ALICE: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
1166    const BOB: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
1167
1168    fn intent(id: u8, state: DeliveryIntentState, bytes: u64) -> DeliveryIntentSnapshot {
1169        DeliveryIntentSnapshot {
1170            intent_id: format!("intent-{id}"),
1171            operation_id: "operation-1".to_owned(),
1172            event_copy_id: hex::encode([id; 32]),
1173            relay_url: "wss://relay.example/".to_owned(),
1174            payload_bytes: bytes,
1175            state,
1176            lease_id: if matches!(
1177                state,
1178                DeliveryIntentState::Claimed | DeliveryIntentState::SocketWritten
1179            ) {
1180                "lease-1".to_owned()
1181            } else {
1182                String::new()
1183            },
1184            attempt_count: 0,
1185        }
1186    }
1187
1188    #[test]
1189    fn account_events_reserve_their_publish_frame_before_acceptance() -> Result<(), SoftchatError> {
1190        let identity = LocalIdentity::from_secret_hex(ALICE)?;
1191        let sign = |content: String| {
1192            identity.sign_event(NostrEventDraft::new(
1193                1_700_000_000,
1194                NostrEventKind::SHORT_TEXT_NOTE,
1195                Vec::new(),
1196                content,
1197            )?)
1198        };
1199        let base_bytes = sign(String::new())?.to_json()?.len();
1200        let envelope_bytes = "[\"EVENT\",]".len();
1201        for event_bytes in [
1202            crate::MAX_RELAY_FRAME_BYTES - envelope_bytes,
1203            crate::MAX_RELAY_FRAME_BYTES - envelope_bytes + 1,
1204        ] {
1205            let event = sign("x".repeat(event_bytes - base_bytes))?;
1206            let json = event.to_json()?;
1207            assert_eq!(json.len(), event_bytes);
1208            assert!(SignedNostrEvent::from_json(&json).is_ok());
1209            let incoming = AccountEngine::prepare_incoming(
1210                &identity,
1211                "frame-account".to_owned(),
1212                vec![SignedEvent::from(&event)],
1213            );
1214            let outgoing = AccountEngine::prepare_signed_operation(
1215                "frame-account".to_owned(),
1216                "frame-command".to_owned(),
1217                vec![SignedEvent::from(&event)],
1218                vec!["wss://relay.example".to_owned()],
1219            );
1220            if event_bytes + envelope_bytes <= crate::MAX_RELAY_FRAME_BYTES {
1221                assert!(incoming.is_ok());
1222                assert!(outgoing.is_ok());
1223                assert_eq!(
1224                    crate::ClientRelayFrame::Event(event).to_json()?.len(),
1225                    crate::MAX_RELAY_FRAME_BYTES
1226                );
1227            } else {
1228                assert!(matches!(incoming, Err(SoftchatError::InvalidRelayFrame)));
1229                assert!(matches!(outgoing, Err(SoftchatError::InvalidRelayFrame)));
1230            }
1231        }
1232        Ok(())
1233    }
1234
1235    #[test]
1236    fn prepares_and_authenticates_account_batches_and_operations() {
1237        let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1238        let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1239        let message = alice
1240            .create_chat_message(ChatMessageDraft {
1241                created_at: 1_700_000_000,
1242                participants: vec![bob.public_key()],
1243                content: "hello".to_owned(),
1244                relation: ChatRelation::None,
1245                attachments: Vec::new(),
1246                emoji_tags: Vec::new(),
1247                extension_tags: Vec::new(),
1248            })
1249            .unwrap();
1250        let operation = AccountEngine::prepare_rumor_operation(
1251            &alice,
1252            "account-1".to_owned(),
1253            "operation-1".to_owned(),
1254            message.rumor.clone(),
1255            vec![bob.public_key().to_hex()],
1256            vec![bob.public_key().to_hex()],
1257            vec!["wss://relay.example".to_owned()],
1258            Nip59EnvelopeKind::Durable,
1259            1_700_172_800,
1260        )
1261        .unwrap();
1262        assert_eq!(operation.event_copies.len(), 2);
1263        assert_eq!(operation.relay_intents.len(), 2);
1264        for copy in &operation.event_copies {
1265            let event = SignedNostrEvent::try_from(copy.event.clone()).unwrap();
1266            let has_alert = event
1267                .tags()
1268                .any(|tag| tag.first().is_some_and(|value| value == "alert"));
1269            assert_eq!(
1270                has_alert,
1271                copy.recipient_public_key == bob.public_key().to_hex()
1272            );
1273        }
1274        assert_ne!(
1275            operation.event_copies[0].event.public_key,
1276            operation.event_copies[1].event.public_key
1277        );
1278
1279        let bob_copy = operation
1280            .event_copies
1281            .iter()
1282            .find(|copy| copy.recipient_public_key == bob.public_key().to_hex())
1283            .unwrap();
1284        let batch = AccountEngine::prepare_incoming(
1285            &bob,
1286            "account-2".to_owned(),
1287            vec![bob_copy.event.clone()],
1288        )
1289        .unwrap();
1290        assert_eq!(batch.events[0].projection.kind, ProjectionKind::ChatMessage);
1291        assert_eq!(batch.events[0].projection.content, "hello");
1292        assert_eq!(batch.events[0].projection.participants.len(), 2);
1293
1294        let receipt = IngestionReceipt {
1295            account_id: "account-2".to_owned(),
1296            inserted_event_ids: vec![bob_copy.event.id.clone()],
1297            duplicate_event_ids: Vec::new(),
1298            quarantined_event_ids: Vec::new(),
1299        };
1300        AccountEngine::validate_ingestion_receipt(&batch, &receipt).unwrap();
1301    }
1302
1303    #[test]
1304    fn account_data_p_tags_do_not_create_chat_conversations() {
1305        let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1306        let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1307        let follow_list = alice
1308            .create_follow_list(
1309                1_700_000_000,
1310                vec![crate::Contact {
1311                    public_key: bob.public_key().to_hex(),
1312                    relay_hint: String::new(),
1313                    local_name: "Bob".to_owned(),
1314                }],
1315            )
1316            .unwrap();
1317        let rumor = NostrRumor::try_from(follow_list.rumor).unwrap();
1318
1319        let projection = projection_for_rumor(&rumor, &alice.public_key(), false).unwrap();
1320
1321        assert_eq!(projection.kind, ProjectionKind::FollowList);
1322        assert!(projection.conversation_id.is_empty());
1323        assert!(projection.participants.is_empty());
1324    }
1325
1326    #[test]
1327    fn conversation_id_matches_the_released_android_hash() {
1328        let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1329        let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1330        let result =
1331            conversation_id(vec![bob.public_key().to_hex(), alice.public_key().to_hex()]).unwrap();
1332        let mut digest = Sha256::new();
1333        let mut keys = [alice.public_key(), bob.public_key()];
1334        keys.sort_by_key(NostrPublicKey::to_hex);
1335        for key in keys {
1336            digest.update(key.as_inner().as_bytes());
1337        }
1338        assert_eq!(result, hex::encode(digest.finalize()));
1339    }
1340
1341    #[test]
1342    fn delivery_claim_and_results_never_treat_socket_write_as_acceptance() {
1343        let claim = DeliveryReducer::claim(
1344            vec![
1345                intent(1, DeliveryIntentState::Pending, 100),
1346                intent(2, DeliveryIntentState::Retryable, 200),
1347            ],
1348            "lease-1".to_owned(),
1349        )
1350        .unwrap();
1351        assert_eq!(claim.payload_bytes, 300);
1352        assert!(
1353            claim
1354                .mutations
1355                .iter()
1356                .all(|mutation| mutation.state == DeliveryIntentState::Claimed)
1357        );
1358
1359        let mut persisted_claim = claim.clone();
1360        for intent in &mut persisted_claim.intents {
1361            intent.state = DeliveryIntentState::Claimed;
1362            intent.lease_id = "lease-1".to_owned();
1363        }
1364        let written = DeliveryReducer::mark_socket_written(
1365            &persisted_claim,
1366            vec!["intent-1".to_owned(), "intent-2".to_owned()],
1367        )
1368        .unwrap();
1369        assert!(
1370            written
1371                .iter()
1372                .all(|mutation| mutation.state == DeliveryIntentState::SocketWritten)
1373        );
1374
1375        for intent in &mut persisted_claim.intents {
1376            intent.state = DeliveryIntentState::SocketWritten;
1377        }
1378        let outcomes = DeliveryReducer::apply_relay_results(
1379            &persisted_claim,
1380            vec![
1381                RelayDeliveryResult {
1382                    intent_id: "intent-1".to_owned(),
1383                    kind: RelayDeliveryResultKind::Accepted,
1384                    category: "accepted".to_owned(),
1385                },
1386                RelayDeliveryResult {
1387                    intent_id: "intent-2".to_owned(),
1388                    kind: RelayDeliveryResultKind::Retryable,
1389                    category: "rate_limited".to_owned(),
1390                },
1391            ],
1392        )
1393        .unwrap();
1394        assert_eq!(outcomes[0].state, DeliveryIntentState::Accepted);
1395        assert_eq!(outcomes[1].state, DeliveryIntentState::Retryable);
1396    }
1397
1398    #[test]
1399    fn operation_state_requires_every_relay_intent_to_accept() {
1400        let queued = DeliveryReducer::operation_snapshot(vec![
1401            intent(1, DeliveryIntentState::Pending, 1),
1402            intent(2, DeliveryIntentState::Retryable, 1),
1403        ])
1404        .unwrap();
1405        assert_eq!(queued.state, OperationState::Queued);
1406
1407        let partial = DeliveryReducer::operation_snapshot(vec![
1408            intent(1, DeliveryIntentState::Accepted, 1),
1409            intent(2, DeliveryIntentState::Retryable, 1),
1410        ])
1411        .unwrap();
1412        assert_eq!(partial.state, OperationState::PartiallySent);
1413
1414        let sent = DeliveryReducer::operation_snapshot(vec![
1415            intent(1, DeliveryIntentState::Accepted, 1),
1416            intent(2, DeliveryIntentState::Accepted, 1),
1417        ])
1418        .unwrap();
1419        assert_eq!(sent.state, OperationState::Sent);
1420    }
1421
1422    #[test]
1423    fn incoming_rejects_duplicates_and_receipt_gaps() {
1424        let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1425        let event = alice
1426            .sign_event(
1427                NostrEventDraft::new(
1428                    1,
1429                    NostrEventKind::APPLICATION_DATA,
1430                    vec![crate::NostrTag::new(vec!["d".to_owned(), "fixture".to_owned()]).unwrap()],
1431                    "content",
1432                )
1433                .unwrap(),
1434            )
1435            .unwrap();
1436        let value = SignedEvent::from(&event);
1437        assert!(matches!(
1438            AccountEngine::prepare_incoming(
1439                &alice,
1440                "account-1".to_owned(),
1441                vec![value.clone(), value]
1442            ),
1443            Err(SoftchatError::InvalidAccountOperation)
1444        ));
1445    }
1446
1447    #[test]
1448    fn incoming_chat_binds_outer_recipient_to_inner_participants() {
1449        let alice = LocalIdentity::from_secret_hex(ALICE).unwrap();
1450        let bob = LocalIdentity::from_secret_hex(BOB).unwrap();
1451        let carol = LocalIdentity::from_secret_hex(
1452            "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
1453        )
1454        .unwrap();
1455
1456        let prepare = |recipient: &LocalIdentity, tags: Vec<crate::NostrTag>| {
1457            let rumor = alice
1458                .create_rumor(
1459                    NostrEventDraft::new(
1460                        1_700_000_000,
1461                        NostrEventKind::PRIVATE_DIRECT_MESSAGE,
1462                        tags,
1463                        "bounded participant fixture",
1464                    )
1465                    .unwrap(),
1466                )
1467                .unwrap();
1468            let outer = alice
1469                .gift_wrap(
1470                    &recipient.public_key(),
1471                    &rumor,
1472                    Nip59EnvelopeKind::Durable,
1473                    1_700_172_800,
1474                )
1475                .unwrap();
1476            AccountEngine::prepare_incoming(
1477                recipient,
1478                "recipient-account".to_owned(),
1479                vec![SignedEvent::from(&outer)],
1480            )
1481        };
1482        let participant_tag = |identity: &LocalIdentity| {
1483            crate::NostrTag::new(vec!["p".to_owned(), identity.public_key().to_hex()]).unwrap()
1484        };
1485
1486        assert!(prepare(&bob, vec![participant_tag(&bob)]).is_ok());
1487        // The author copy is valid because the authenticated recipient is the
1488        // rumor author and the inner room still contains another participant.
1489        assert!(prepare(&alice, vec![participant_tag(&bob)]).is_ok());
1490        assert!(matches!(
1491            prepare(&bob, vec![participant_tag(&carol)]),
1492            Err(SoftchatError::InvalidSoftchatEvent)
1493        ));
1494        assert!(matches!(
1495            prepare(&bob, vec![participant_tag(&bob), participant_tag(&bob)]),
1496            Err(SoftchatError::InvalidSoftchatEvent)
1497        ));
1498        assert!(matches!(
1499            prepare(&alice, Vec::new()),
1500            Err(SoftchatError::InvalidSoftchatEvent)
1501        ));
1502
1503        let mut oversized = vec![participant_tag(&bob)];
1504        for value in 1_u16..=crate::MAX_CHAT_PARTICIPANTS as u16 {
1505            let identity = LocalIdentity::from_secret_hex(&format!("{value:064x}")).unwrap();
1506            oversized.push(participant_tag(&identity));
1507        }
1508        assert!(oversized.len() > crate::MAX_CHAT_PARTICIPANTS);
1509        assert!(matches!(
1510            prepare(&bob, oversized),
1511            Err(SoftchatError::InvalidSoftchatEvent)
1512        ));
1513    }
1514}